From eb45d1775822036c9ae5aedd881b2fe3bc346389 Mon Sep 17 00:00:00 2001 From: plainheart Date: Thu, 18 Dec 2025 01:42:25 +0800 Subject: [PATCH 01/31] fix(axis): fix axis label may have inappropriate precision or take too much unexpected space when `alignTicks` is enabled --- src/coord/axisAlignTicks.ts | 2 +- test/axis-align-ticks.html | 351 ++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 1 deletion(-) diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 5810d01f78..3080cf69d2 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -101,7 +101,7 @@ export function alignScaleTicks( } const range = interval * alignToSplitNumber; - max = Math.ceil(rawExtent[1] / interval) * interval; + max = round(Math.ceil(rawExtent[1] / interval) * interval); min = round(max - range); // Not change the result that crossing zero. if (min < 0 && rawExtent[0] >= 0) { diff --git a/test/axis-align-ticks.html b/test/axis-align-ticks.html index c1e62500be..b0a761a848 100644 --- a/test/axis-align-ticks.html +++ b/test/axis-align-ticks.html @@ -44,6 +44,8 @@
+
+
@@ -406,6 +408,355 @@ }); }); + + + + + + From 4cf7cb4cfb243ac6dfe28ac04fd77e49a872a202 Mon Sep 17 00:00:00 2001 From: 100pah Date: Thu, 8 Jan 2026 16:57:11 +0800 Subject: [PATCH 02/31] test(dataZoom): fix test case -- accidentally broken in v6. And add more case for dataZoom edge cases. --- test/dataZoom-action.html | 658 ++++++++++++++++++---- test/runTest/actions/__meta__.json | 2 +- test/runTest/actions/dataZoom-action.json | 2 +- 3 files changed, 540 insertions(+), 122 deletions(-) diff --git a/test/dataZoom-action.html b/test/dataZoom-action.html index fb34b42616..5b3651d9f0 100644 --- a/test/dataZoom-action.html +++ b/test/dataZoom-action.html @@ -49,105 +49,204 @@
-
-
-
+
+
+
+
+
@@ -328,7 +427,7 @@ var now = new Date(base += oneDay); var cat = [now.getFullYear(), now.getMonth() + 1, now.getDate()].join('-'); category.push(cat); - value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data1[i - 1])); + var value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data1[i - 1])); data1.push(value); if (i === 40) { specialNormal[0] = cat; @@ -346,6 +445,9 @@ } } + var minSpan = 5; + var maxSpan = 30; + var option = { tooltip: { trigger: 'axis' @@ -373,15 +475,15 @@ id: 'dz-in', start: 0, end: 10, - minSpan: 5, - maxSpan: 30, + minSpan: minSpan, + maxSpan: maxSpan, xAxisIndex: 0 }, { id: 'dz-s', start: 0, end: 10, - minSpan: 5, - maxSpan: 30, + minSpan: minSpan, + maxSpan: maxSpan, xAxisIndex: 0 }], series: [{ @@ -398,7 +500,7 @@ var ctx = { hint: 'category axis value should be integer', - percentButttons: getDefaultPercentButtons(), + percentButttons: getDefaultPercentButtons({minSpan, maxSpan}), valueButtons: [{ text: getBtnLabel(specialNormal), startValue: specialNormal[0], @@ -414,7 +516,7 @@ }, { }] }; - ctx.chart = testHelper.create(echarts, 'main2', { + ctx.chart = testHelper.create(echarts, 'main_categoryX_hasMinSpan_hasMaxSpan', { option: option, title: [ '(category axis) dispatchAction: {type: "dataZoom"}', @@ -448,7 +550,7 @@ for (var i = 0; i < 100; i++) { var now = new Date(base += oneDay); - value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data2[i - 1][1])); + var value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data2[i - 1][1])); data2.push([now, value]); if (i === 30) { specialNormal[0] = +now; @@ -466,6 +568,9 @@ } } + var minSpan = 5; + var maxSpan = 30; + var option = { tooltip: { trigger: 'axis', @@ -490,14 +595,14 @@ dataZoom: [{ type: 'inside', id: 'dz-in', - maxSpan: 30, - minSpan: 5, + minSpan: minSpan, + maxSpan: maxSpan, start: 0, end: 10 }, { id: 'dz-s', - maxSpan: 30, - minSpan: 5, + minSpan: minSpan, + maxSpan: maxSpan, start: 0, end: 10 }], @@ -520,7 +625,7 @@ var ctx = { hint: 'time axis value should be integer', - percentButttons: getDefaultPercentButtons(), + percentButttons: getDefaultPercentButtons({minSpan, maxSpan}), valueButtons: [{ text: getBtnLabel(specialNormal), startValue: fmt2Str(specialNormal[0]), @@ -535,7 +640,7 @@ endValue: fmt2Str(specialLong[1]) }] }; - ctx.chart = testHelper.create(echarts, 'main3', { + ctx.chart = testHelper.create(echarts, 'main_timeX_hasMinSpan_hasMaxSpan', { option: option, title: [ '(time axis) dispatchAction: {type: "dataZoom"}', @@ -552,17 +657,6 @@ - - - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index c78118d8db..6d72723021 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -77,7 +77,7 @@ "custom-update": 4, "dataSelect": 7, "dataset-case": 6, - "dataZoom-action": 4, + "dataZoom-action": 6, "dataZoom-axes": 4, "dataZoom-axis-type": 3, "dataZoom-clip": 3, diff --git a/test/runTest/actions/dataZoom-action.json b/test/runTest/actions/dataZoom-action.json index a0b430e961..cec9dd88ae 100644 --- a/test/runTest/actions/dataZoom-action.json +++ b/test/runTest/actions/dataZoom-action.json @@ -1 +1 @@ -[{"name":"Action 1","ops":[{"type":"mousedown","time":392,"x":113,"y":59},{"type":"mouseup","time":475,"x":113,"y":59},{"time":476,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1429,"x":121,"y":59},{"type":"mousemove","time":1630,"x":196,"y":60},{"type":"mousemove","time":1830,"x":197,"y":61},{"type":"mousedown","time":2072,"x":197,"y":61},{"type":"mouseup","time":2187,"x":197,"y":61},{"time":2188,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3281,"x":198,"y":60},{"type":"mousemove","time":3482,"x":264,"y":57},{"type":"mousemove","time":3682,"x":296,"y":62},{"type":"mousedown","time":3738,"x":296,"y":62},{"type":"mouseup","time":3854,"x":296,"y":62},{"time":3855,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3893,"x":296,"y":62}],"scrollY":0,"scrollX":0,"timestamp":1603889111253},{"name":"Action 2","ops":[{"type":"mousedown","time":446,"x":99,"y":201},{"type":"mouseup","time":530,"x":99,"y":201},{"time":531,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":808,"x":100,"y":201},{"type":"mousemove","time":1008,"x":222,"y":197},{"type":"mousemove","time":1209,"x":222,"y":197},{"type":"mousedown","time":1268,"x":222,"y":197},{"type":"mouseup","time":1351,"x":222,"y":197},{"time":1352,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1658,"x":230,"y":197},{"type":"mousemove","time":1858,"x":483,"y":210},{"type":"mousemove","time":2068,"x":487,"y":210},{"type":"mousedown","time":2207,"x":487,"y":210},{"type":"mouseup","time":2288,"x":487,"y":210},{"time":2289,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2377,"x":488,"y":210},{"type":"mousemove","time":2583,"x":538,"y":206},{"type":"mousemove","time":2799,"x":562,"y":204},{"type":"mousedown","time":3039,"x":562,"y":204},{"type":"mouseup","time":3120,"x":562,"y":204},{"time":3121,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3511,"x":560,"y":204},{"type":"mousemove","time":3711,"x":164,"y":235},{"type":"mousemove","time":3912,"x":73,"y":237},{"type":"mousemove","time":4119,"x":77,"y":243},{"type":"mousedown","time":4344,"x":77,"y":243},{"type":"mouseup","time":4426,"x":77,"y":243},{"time":4427,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4603,"x":78,"y":243},{"type":"mousemove","time":4803,"x":272,"y":242},{"type":"mousemove","time":5013,"x":280,"y":242},{"type":"mousedown","time":5213,"x":280,"y":242},{"type":"mouseup","time":5296,"x":280,"y":242},{"time":5297,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5487,"x":280,"y":243},{"type":"mousemove","time":5687,"x":246,"y":277},{"type":"mousemove","time":5897,"x":237,"y":285},{"type":"mousedown","time":6095,"x":238,"y":284},{"type":"mousemove","time":6128,"x":238,"y":284},{"type":"mouseup","time":6162,"x":238,"y":284},{"time":6163,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6554,"x":242,"y":284},{"type":"mousemove","time":6755,"x":417,"y":280},{"type":"mousemove","time":6963,"x":461,"y":277},{"type":"mousedown","time":7063,"x":461,"y":277},{"type":"mouseup","time":7148,"x":461,"y":277},{"time":7149,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7179,"x":461,"y":277},{"type":"mousemove","time":7422,"x":461,"y":277},{"type":"mousemove","time":7624,"x":248,"y":308},{"type":"mousemove","time":7830,"x":56,"y":326},{"type":"mousemove","time":8040,"x":48,"y":330},{"type":"mousedown","time":8086,"x":48,"y":330},{"type":"mouseup","time":8197,"x":48,"y":330},{"time":8198,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8246,"x":48,"y":330}],"scrollY":292,"scrollX":0,"timestamp":1603889154680},{"name":"Action 3","ops":[{"type":"mousedown","time":505,"x":99,"y":202},{"type":"mouseup","time":584,"x":99,"y":202},{"time":585,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":845,"x":113,"y":201},{"type":"mousemove","time":1045,"x":262,"y":198},{"type":"mousemove","time":1254,"x":269,"y":196},{"type":"mousedown","time":1412,"x":269,"y":196},{"type":"mouseup","time":1503,"x":269,"y":196},{"time":1504,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1730,"x":272,"y":196},{"type":"mousemove","time":1930,"x":452,"y":207},{"type":"mousemove","time":2136,"x":461,"y":205},{"type":"mousedown","time":2304,"x":461,"y":205},{"type":"mouseup","time":2373,"x":461,"y":205},{"time":2374,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2431,"x":462,"y":204},{"type":"mousemove","time":2642,"x":603,"y":206},{"type":"mousemove","time":2857,"x":605,"y":205},{"type":"mousedown","time":3241,"x":605,"y":205},{"type":"mouseup","time":3331,"x":605,"y":205},{"time":3332,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3714,"x":586,"y":215},{"type":"mousemove","time":3915,"x":393,"y":248},{"type":"mousemove","time":4115,"x":353,"y":255},{"type":"mousemove","time":4321,"x":169,"y":260},{"type":"mousemove","time":4532,"x":168,"y":250},{"type":"mousedown","time":4666,"x":168,"y":249},{"type":"mousemove","time":4732,"x":168,"y":249},{"type":"mouseup","time":4747,"x":168,"y":249},{"time":4748,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4765,"x":168,"y":248},{"type":"mousemove","time":4969,"x":168,"y":248},{"type":"mousemove","time":5040,"x":169,"y":248},{"type":"mousemove","time":5240,"x":316,"y":236},{"type":"mousemove","time":5449,"x":317,"y":236},{"type":"mousemove","time":5474,"x":317,"y":236},{"type":"mousedown","time":5581,"x":317,"y":237},{"type":"mouseup","time":5648,"x":317,"y":237},{"time":5649,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5691,"x":317,"y":237},{"type":"mousemove","time":6025,"x":317,"y":237},{"type":"mousemove","time":6225,"x":306,"y":275},{"type":"mousemove","time":6425,"x":305,"y":277},{"type":"mousedown","time":6587,"x":305,"y":277},{"type":"mouseup","time":6650,"x":305,"y":277},{"time":6651,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6959,"x":305,"y":278},{"type":"mousemove","time":7159,"x":291,"y":307},{"type":"mousemove","time":7361,"x":289,"y":314},{"type":"mousedown","time":7551,"x":285,"y":320},{"type":"mousemove","time":7570,"x":284,"y":320},{"type":"mouseup","time":7636,"x":284,"y":321},{"time":7637,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7788,"x":284,"y":321},{"type":"mousemove","time":8161,"x":284,"y":321},{"type":"mousemove","time":8370,"x":283,"y":321}],"scrollY":752,"scrollX":0,"timestamp":1603889205230},{"name":"Action 4","ops":[{"type":"mousedown","time":467,"x":134,"y":299},{"type":"mouseup","time":616,"x":134,"y":299},{"time":617,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":923,"x":144,"y":298},{"type":"mousemove","time":1130,"x":271,"y":297},{"type":"mousedown","time":1380,"x":271,"y":297},{"type":"mouseup","time":1469,"x":271,"y":297},{"time":1470,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1841,"x":272,"y":297},{"type":"mousemove","time":2041,"x":426,"y":295},{"type":"mousemove","time":2248,"x":441,"y":295},{"type":"mousedown","time":2282,"x":441,"y":295},{"type":"mouseup","time":2365,"x":441,"y":295},{"time":2366,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2492,"x":441,"y":295},{"type":"mousemove","time":2859,"x":442,"y":295},{"type":"mousemove","time":3059,"x":563,"y":292},{"type":"mousemove","time":3259,"x":566,"y":292},{"type":"mousedown","time":3287,"x":566,"y":292},{"type":"mouseup","time":3383,"x":566,"y":292},{"time":3384,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3860,"x":565,"y":292},{"type":"mousemove","time":4071,"x":124,"y":359},{"type":"mousemove","time":4277,"x":117,"y":356},{"type":"mousemove","time":4484,"x":118,"y":346},{"type":"mousedown","time":4502,"x":118,"y":346},{"type":"mouseup","time":4585,"x":118,"y":346},{"time":4586,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4844,"x":120,"y":346},{"type":"mousemove","time":5044,"x":283,"y":342},{"type":"mousemove","time":5256,"x":303,"y":341},{"type":"mousedown","time":5407,"x":303,"y":341},{"type":"mouseup","time":5477,"x":303,"y":341},{"time":5478,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5745,"x":303,"y":342},{"type":"mousemove","time":5960,"x":231,"y":369},{"type":"mousemove","time":6162,"x":218,"y":377},{"type":"mousedown","time":6180,"x":218,"y":377},{"type":"mouseup","time":6253,"x":217,"y":378},{"time":6254,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6362,"x":217,"y":378}],"scrollY":1132,"scrollX":0,"timestamp":1603889220515}] \ No newline at end of file +[{"name":"Action 1","ops":[{"type":"mousedown","time":392,"x":113,"y":59},{"type":"mouseup","time":475,"x":113,"y":59},{"time":476,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1429,"x":121,"y":59},{"type":"mousemove","time":1630,"x":196,"y":60},{"type":"mousemove","time":1830,"x":197,"y":61},{"type":"mousedown","time":2072,"x":197,"y":61},{"type":"mouseup","time":2187,"x":197,"y":61},{"time":2188,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3281,"x":198,"y":60},{"type":"mousemove","time":3482,"x":264,"y":57},{"type":"mousemove","time":3682,"x":296,"y":62},{"type":"mousedown","time":3738,"x":296,"y":62},{"type":"mouseup","time":3854,"x":296,"y":62},{"time":3855,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3893,"x":296,"y":62}],"scrollY":0,"scrollX":0,"timestamp":1603889111253},{"name":"Action 2","ops":[{"type":"mousemove","time":69,"x":795,"y":239},{"type":"mousemove","time":277,"x":276,"y":216},{"type":"mousemove","time":469,"x":273,"y":216},{"type":"mousemove","time":669,"x":200,"y":177},{"type":"mousemove","time":869,"x":161,"y":138},{"type":"mousemove","time":1069,"x":144,"y":124},{"type":"mousedown","time":1193,"x":143,"y":123},{"type":"mousemove","time":1278,"x":143,"y":123},{"type":"mouseup","time":1326,"x":143,"y":123},{"time":1327,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1469,"x":143,"y":123},{"type":"mousemove","time":1669,"x":205,"y":120},{"type":"mousedown","time":1843,"x":233,"y":120},{"type":"mousemove","time":1876,"x":233,"y":120},{"type":"mouseup","time":1910,"x":233,"y":120},{"time":1911,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2103,"x":234,"y":120},{"type":"mousemove","time":2310,"x":429,"y":110},{"type":"mousemove","time":2520,"x":433,"y":113},{"type":"mousedown","time":2693,"x":433,"y":124},{"type":"mousemove","time":2727,"x":433,"y":124},{"type":"mouseup","time":2810,"x":433,"y":124},{"time":2811,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3003,"x":435,"y":124},{"type":"mousemove","time":3211,"x":536,"y":126},{"type":"mousedown","time":3377,"x":543,"y":124},{"type":"mousemove","time":3426,"x":543,"y":124},{"type":"mouseup","time":3461,"x":543,"y":124},{"time":3462,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3654,"x":539,"y":124},{"type":"mousemove","time":3862,"x":438,"y":162},{"type":"mousedown","time":3995,"x":438,"y":162},{"type":"mouseup","time":4094,"x":438,"y":162},{"time":4095,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4117,"x":438,"y":162},{"type":"mousemove","time":4369,"x":438,"y":162},{"type":"mousemove","time":4569,"x":211,"y":157},{"type":"mousemove","time":4770,"x":198,"y":156},{"type":"mousedown","time":4786,"x":198,"y":156},{"type":"mouseup","time":4914,"x":198,"y":156},{"time":4915,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4980,"x":198,"y":156},{"type":"mousemove","time":5137,"x":198,"y":158},{"type":"mousemove","time":5337,"x":199,"y":182},{"type":"mousemove","time":5544,"x":199,"y":199},{"type":"mousedown","time":5665,"x":199,"y":200},{"type":"mousemove","time":5761,"x":199,"y":200},{"type":"mouseup","time":5798,"x":199,"y":200},{"time":5799,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6003,"x":200,"y":200},{"type":"mousemove","time":6203,"x":404,"y":215},{"type":"mousemove","time":6410,"x":442,"y":219},{"type":"mousemove","time":6628,"x":447,"y":204},{"type":"mousedown","time":6749,"x":447,"y":204},{"type":"mouseup","time":6899,"x":447,"y":204},{"time":6900,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8070,"x":447,"y":205},{"type":"mousemove","time":8270,"x":447,"y":226},{"type":"mousemove","time":8470,"x":448,"y":234},{"type":"mousemove","time":8678,"x":448,"y":235},{"type":"mousemove","time":8753,"x":448,"y":236},{"type":"mousemove","time":8953,"x":425,"y":407},{"type":"mousemove","time":9153,"x":433,"y":394},{"type":"mousemove","time":9353,"x":453,"y":371},{"type":"mousemove","time":9565,"x":455,"y":368},{"type":"mousewheel","time":9720,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":9746,"x":455,"y":368,"deltaY":3},{"type":"mousewheel","time":9763,"x":455,"y":368,"deltaY":3},{"type":"mousewheel","time":9781,"x":455,"y":368,"deltaY":3},{"type":"mousewheel","time":9799,"x":455,"y":368,"deltaY":3},{"type":"mousewheel","time":9817,"x":455,"y":368,"deltaY":2},{"type":"mousewheel","time":9836,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":9862,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":9881,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":9953,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":9972,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":9989,"x":455,"y":368,"deltaY":1},{"type":"mousewheel","time":10008,"x":455,"y":368,"deltaY":1}],"scrollY":377,"scrollX":0,"timestamp":1767784033459},{"name":"Action 3","ops":[{"type":"mousemove","time":191,"x":709,"y":246},{"type":"mousemove","time":391,"x":164,"y":140},{"type":"mousemove","time":640,"x":162,"y":139},{"type":"mousedown","time":815,"x":149,"y":127},{"type":"mousemove","time":848,"x":149,"y":127},{"type":"mouseup","time":931,"x":149,"y":127},{"time":932,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1057,"x":198,"y":127},{"type":"mousemove","time":1257,"x":222,"y":126},{"type":"mousedown","time":1281,"x":222,"y":126},{"type":"mouseup","time":1365,"x":222,"y":126},{"time":1366,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1540,"x":225,"y":126},{"type":"mousemove","time":1741,"x":398,"y":130},{"type":"mousedown","time":1948,"x":419,"y":128},{"type":"mousemove","time":1964,"x":419,"y":128},{"type":"mouseup","time":2031,"x":419,"y":128},{"time":2032,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2207,"x":421,"y":128},{"type":"mousemove","time":2407,"x":550,"y":123},{"type":"mousedown","time":2585,"x":572,"y":123},{"type":"mousemove","time":2615,"x":572,"y":123},{"type":"mouseup","time":2685,"x":572,"y":123},{"time":2686,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2874,"x":569,"y":123},{"type":"mousemove","time":3082,"x":159,"y":181},{"type":"mousemove","time":3324,"x":158,"y":181},{"type":"mousemove","time":3525,"x":120,"y":181},{"type":"mousemove","time":3731,"x":129,"y":167},{"type":"mousedown","time":3749,"x":129,"y":167},{"type":"mouseup","time":3832,"x":129,"y":167},{"time":3833,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3957,"x":129,"y":167},{"type":"mousemove","time":4075,"x":130,"y":167},{"type":"mousemove","time":4282,"x":210,"y":167},{"type":"mousemove","time":4491,"x":245,"y":167},{"type":"mousedown","time":4603,"x":249,"y":168},{"type":"mouseup","time":4690,"x":249,"y":168},{"time":4691,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4721,"x":249,"y":168},{"type":"mousemove","time":4940,"x":248,"y":169},{"type":"mousemove","time":5151,"x":222,"y":206},{"type":"mousedown","time":5334,"x":222,"y":206},{"type":"mousemove","time":5382,"x":222,"y":206},{"type":"mouseup","time":5432,"x":222,"y":206},{"time":5433,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5657,"x":222,"y":207},{"type":"mousemove","time":5858,"x":222,"y":236},{"type":"mousemove","time":6067,"x":223,"y":244},{"type":"mousedown","time":6167,"x":223,"y":244},{"type":"mouseup","time":6266,"x":223,"y":244},{"time":6267,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6302,"x":223,"y":244},{"type":"mousemove","time":6591,"x":225,"y":248},{"type":"mousemove","time":6803,"x":344,"y":335},{"type":"mousemove","time":7018,"x":352,"y":361},{"type":"mousewheel","time":7207,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7234,"x":352,"y":361,"deltaY":6},{"type":"mousewheel","time":7254,"x":352,"y":361,"deltaY":5},{"type":"mousewheel","time":7273,"x":352,"y":361,"deltaY":9},{"type":"mousewheel","time":7294,"x":352,"y":361,"deltaY":6},{"type":"mousewheel","time":7314,"x":352,"y":361,"deltaY":5},{"type":"mousewheel","time":7343,"x":352,"y":361,"deltaY":7},{"type":"mousewheel","time":7366,"x":352,"y":361,"deltaY":2},{"type":"mousewheel","time":7389,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7418,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7474,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7494,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7515,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7536,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7564,"x":352,"y":361,"deltaY":2},{"type":"mousewheel","time":7585,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7608,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7628,"x":352,"y":361,"deltaY":2},{"type":"mousewheel","time":7648,"x":352,"y":361,"deltaY":1},{"type":"mousewheel","time":7824,"x":352,"y":361,"deltaY":-1},{"type":"mousewheel","time":7850,"x":352,"y":361,"deltaY":-1},{"type":"mousewheel","time":7871,"x":352,"y":361,"deltaY":-1},{"type":"mousewheel","time":7892,"x":352,"y":361,"deltaY":-3},{"type":"mousewheel","time":7913,"x":352,"y":361,"deltaY":-2},{"type":"mousewheel","time":7934,"x":352,"y":361,"deltaY":-2},{"type":"mousewheel","time":7961,"x":352,"y":361,"deltaY":-3},{"type":"mousewheel","time":7983,"x":352,"y":361,"deltaY":-1},{"type":"mousewheel","time":8004,"x":352,"y":361,"deltaY":-1},{"type":"mousemove","time":8191,"x":354,"y":361},{"type":"mousemove","time":8391,"x":682,"y":369},{"type":"mousemove","time":8606,"x":683,"y":369},{"type":"mousemove","time":8674,"x":685,"y":367},{"type":"mousemove","time":8874,"x":746,"y":374},{"type":"mousedown","time":8987,"x":748,"y":374},{"type":"mousemove","time":9074,"x":748,"y":374},{"type":"mouseup","time":9084,"x":748,"y":374},{"time":9085,"delay":400,"type":"screenshot-auto"}],"scrollY":835,"scrollX":0,"timestamp":1767784051205},{"name":"Action 4","ops":[{"type":"mousemove","time":285,"x":722,"y":227},{"type":"mousemove","time":492,"x":68,"y":204},{"type":"mousemove","time":700,"x":38,"y":199},{"type":"mousedown","time":842,"x":38,"y":199},{"type":"mouseup","time":992,"x":38,"y":199},{"time":993,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1116,"x":39,"y":199},{"type":"mousemove","time":1316,"x":126,"y":194},{"type":"mousemove","time":1516,"x":181,"y":193},{"type":"mousemove","time":1716,"x":190,"y":200},{"type":"mousedown","time":1809,"x":191,"y":201},{"type":"mouseup","time":1924,"x":191,"y":201},{"time":1925,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1952,"x":191,"y":201},{"type":"mousemove","time":2333,"x":193,"y":201},{"type":"mousemove","time":2534,"x":247,"y":205},{"type":"mousemove","time":2742,"x":296,"y":202},{"type":"mousedown","time":2792,"x":298,"y":202},{"type":"mouseup","time":2892,"x":298,"y":202},{"time":2893,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2960,"x":298,"y":202},{"type":"mousemove","time":3067,"x":301,"y":202},{"type":"mousemove","time":3276,"x":435,"y":199},{"type":"mousedown","time":3442,"x":462,"y":198},{"type":"mousemove","time":3493,"x":462,"y":198},{"type":"mouseup","time":3561,"x":462,"y":198},{"time":3562,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3667,"x":464,"y":198},{"type":"mousemove","time":3867,"x":568,"y":203},{"type":"mousemove","time":4067,"x":595,"y":203},{"type":"mousedown","time":4097,"x":595,"y":203},{"type":"mouseup","time":4229,"x":595,"y":203},{"time":4230,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4266,"x":595,"y":203},{"type":"mousemove","time":4466,"x":361,"y":226},{"type":"mousemove","time":4667,"x":288,"y":228},{"type":"mousemove","time":4867,"x":267,"y":237},{"type":"mousedown","time":4897,"x":267,"y":237},{"type":"mouseup","time":5059,"x":267,"y":237},{"time":5060,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5268,"x":268,"y":237},{"type":"mousemove","time":5478,"x":341,"y":237},{"type":"mousedown","time":5660,"x":374,"y":235},{"type":"mousemove","time":5693,"x":374,"y":235},{"type":"mouseup","time":5743,"x":374,"y":235},{"time":5744,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5984,"x":374,"y":236},{"type":"mousemove","time":6184,"x":325,"y":335},{"type":"mousemove","time":6395,"x":318,"y":342},{"type":"mousewheel","time":6717,"x":318,"y":342,"deltaY":-1},{"type":"mousewheel","time":6747,"x":318,"y":342,"deltaY":-3},{"type":"mousewheel","time":6770,"x":318,"y":342,"deltaY":-15},{"type":"mousewheel","time":6792,"x":318,"y":342,"deltaY":-6},{"type":"mousewheel","time":6815,"x":318,"y":342,"deltaY":-5},{"type":"mousewheel","time":6838,"x":318,"y":342,"deltaY":-7},{"type":"mousewheel","time":6869,"x":318,"y":342,"deltaY":-6},{"type":"mousewheel","time":6892,"x":318,"y":342,"deltaY":-2},{"type":"mousewheel","time":6922,"x":318,"y":342,"deltaY":-4},{"type":"mousewheel","time":6945,"x":318,"y":342,"deltaY":-2},{"type":"mousewheel","time":6975,"x":318,"y":342,"deltaY":-4},{"type":"mousewheel","time":6998,"x":318,"y":342,"deltaY":-2},{"type":"mousewheel","time":7020,"x":318,"y":342,"deltaY":-4},{"type":"mousewheel","time":7043,"x":318,"y":342,"deltaY":-1},{"type":"mousewheel","time":7077,"x":318,"y":342,"deltaY":-2},{"type":"mousewheel","time":7133,"x":318,"y":342,"deltaY":0},{"type":"mousewheel","time":7156,"x":318,"y":342,"deltaY":0},{"type":"mousewheel","time":7213,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7237,"x":318,"y":342,"deltaY":2},{"type":"mousewheel","time":7261,"x":318,"y":342,"deltaY":4},{"type":"mousewheel","time":7288,"x":318,"y":342,"deltaY":11},{"type":"mousewheel","time":7320,"x":318,"y":342,"deltaY":14},{"type":"mousewheel","time":7346,"x":318,"y":342,"deltaY":8},{"type":"mousewheel","time":7370,"x":318,"y":342,"deltaY":17},{"type":"mousewheel","time":7396,"x":318,"y":342,"deltaY":7},{"type":"mousewheel","time":7430,"x":318,"y":342,"deltaY":10},{"type":"mousewheel","time":7453,"x":318,"y":342,"deltaY":8},{"type":"mousewheel","time":7476,"x":318,"y":342,"deltaY":3},{"type":"mousewheel","time":7503,"x":318,"y":342,"deltaY":5},{"type":"mousewheel","time":7534,"x":318,"y":342,"deltaY":2},{"type":"mousewheel","time":7557,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7581,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7608,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7646,"x":318,"y":342,"deltaY":2},{"type":"mousewheel","time":7670,"x":318,"y":342,"deltaY":3},{"type":"mousewheel","time":7694,"x":318,"y":342,"deltaY":4},{"type":"mousewheel","time":7719,"x":318,"y":342,"deltaY":15},{"type":"mousewheel","time":7751,"x":318,"y":342,"deltaY":6},{"type":"mousewheel","time":7777,"x":318,"y":342,"deltaY":10},{"type":"mousewheel","time":7803,"x":318,"y":342,"deltaY":3},{"type":"mousewheel","time":7831,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7863,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7887,"x":318,"y":342,"deltaY":2},{"type":"mousewheel","time":7915,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":7939,"x":318,"y":342,"deltaY":2},{"type":"mousewheel","time":7972,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":8000,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":8028,"x":318,"y":342,"deltaY":2},{"type":"mousewheel","time":8052,"x":318,"y":342,"deltaY":1},{"type":"mousewheel","time":8484,"x":318,"y":342,"deltaY":-1},{"type":"mousewheel","time":8514,"x":318,"y":342,"deltaY":-11},{"type":"mousewheel","time":8538,"x":318,"y":342,"deltaY":-38},{"type":"mousewheel","time":8562,"x":318,"y":342,"deltaY":-31},{"type":"mousewheel","time":8585,"x":318,"y":342,"deltaY":-73},{"type":"mousewheel","time":8614,"x":318,"y":342,"deltaY":-30},{"type":"mousewheel","time":8638,"x":318,"y":342,"deltaY":-48},{"type":"mousewheel","time":8661,"x":318,"y":342,"deltaY":-15},{"type":"mousewheel","time":8684,"x":318,"y":342,"deltaY":-22},{"type":"mousewheel","time":8721,"x":318,"y":342,"deltaY":-20},{"type":"mousewheel","time":8747,"x":318,"y":342,"deltaY":-68},{"type":"mousewheel","time":8772,"x":318,"y":342,"deltaY":-23},{"type":"mousewheel","time":8798,"x":318,"y":342,"deltaY":-47},{"type":"mousewheel","time":8828,"x":318,"y":342,"deltaY":-46},{"type":"mousewheel","time":8851,"x":318,"y":342,"deltaY":-20},{"type":"mousewheel","time":8874,"x":318,"y":342,"deltaY":-36},{"type":"mousewheel","time":8898,"x":318,"y":342,"deltaY":-16},{"type":"mousewheel","time":8923,"x":318,"y":342,"deltaY":-27},{"type":"mousewheel","time":9006,"x":318,"y":342,"deltaY":-11},{"type":"mousewheel","time":9029,"x":318,"y":342,"deltaY":-19},{"type":"mousewheel","time":9055,"x":318,"y":342,"deltaY":-57},{"type":"mousewheel","time":9079,"x":318,"y":342,"deltaY":-76},{"type":"mousewheel","time":9103,"x":318,"y":342,"deltaY":-75},{"type":"mousewheel","time":9126,"x":318,"y":342,"deltaY":-112},{"type":"mousewheel","time":9148,"x":318,"y":342,"deltaY":-354},{"type":"mousewheel","time":9171,"x":318,"y":342,"deltaY":-216},{"type":"mousewheel","time":9197,"x":318,"y":342,"deltaY":-100},{"type":"mousewheel","time":9218,"x":318,"y":342,"deltaY":-96},{"type":"mousewheel","time":9242,"x":318,"y":342,"deltaY":-179},{"type":"mousewheel","time":9265,"x":318,"y":342,"deltaY":-82},{"type":"mousewheel","time":9287,"x":318,"y":342,"deltaY":-77},{"type":"mousewheel","time":9309,"x":318,"y":342,"deltaY":-140},{"type":"mousewheel","time":9331,"x":318,"y":342,"deltaY":-63},{"type":"mousewheel","time":9354,"x":318,"y":342,"deltaY":-114},{"type":"mousewheel","time":9376,"x":318,"y":342,"deltaY":-51},{"type":"mousewheel","time":9402,"x":318,"y":342,"deltaY":-47},{"type":"mousewheel","time":9429,"x":318,"y":342,"deltaY":-84},{"type":"mousewheel","time":9450,"x":318,"y":342,"deltaY":-37},{"type":"mousewheel","time":9472,"x":318,"y":342,"deltaY":-66},{"type":"mousewheel","time":9499,"x":318,"y":342,"deltaY":-29},{"type":"mousewheel","time":9522,"x":318,"y":342,"deltaY":-26},{"type":"mousewheel","time":9573,"x":318,"y":342,"deltaY":-2},{"type":"mousewheel","time":9600,"x":318,"y":342,"deltaY":-8},{"type":"mousewheel","time":9622,"x":318,"y":342,"deltaY":-18},{"type":"mousewheel","time":9644,"x":318,"y":342,"deltaY":-29},{"type":"mousewheel","time":9668,"x":318,"y":342,"deltaY":-32},{"type":"mousewheel","time":9691,"x":318,"y":342,"deltaY":-47},{"type":"mousewheel","time":9715,"x":318,"y":342,"deltaY":-19},{"type":"mousewheel","time":9742,"x":318,"y":342,"deltaY":-21},{"type":"mousewheel","time":9766,"x":318,"y":342,"deltaY":-7},{"type":"mousewheel","time":9790,"x":318,"y":342,"deltaY":-5},{"type":"mousewheel","time":9819,"x":318,"y":342,"deltaY":-6},{"type":"mousewheel","time":9845,"x":318,"y":342,"deltaY":-14},{"type":"mousewheel","time":9870,"x":318,"y":342,"deltaY":-14},{"type":"mousewheel","time":9897,"x":318,"y":342,"deltaY":-14},{"type":"mousewheel","time":9925,"x":318,"y":342,"deltaY":-7},{"type":"mousewheel","time":9952,"x":318,"y":342,"deltaY":-12},{"type":"mousewheel","time":9978,"x":318,"y":342,"deltaY":-5},{"type":"mousemove","time":10067,"x":319,"y":349},{"type":"mousemove","time":10271,"x":226,"y":443},{"type":"mousemove","time":10482,"x":238,"y":414},{"type":"mousemove","time":10703,"x":239,"y":412},{"type":"mousemove","time":10996,"x":239,"y":412},{"type":"mousedown","time":11064,"x":239,"y":412},{"type":"mousemove","time":11077,"x":241,"y":412},{"type":"mousemove","time":11290,"x":467,"y":404},{"type":"mousemove","time":11518,"x":467,"y":404},{"type":"mouseup","time":11567,"x":467,"y":404},{"time":11568,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11580,"x":476,"y":369},{"type":"mousemove","time":11785,"x":460,"y":360},{"type":"mousemove","time":12014,"x":458,"y":362},{"type":"mousewheel","time":12139,"x":458,"y":362,"deltaY":-2},{"type":"mousewheel","time":12173,"x":458,"y":362,"deltaY":-8},{"type":"mousewheel","time":12212,"x":458,"y":362,"deltaY":-4},{"type":"mousewheel","time":12244,"x":458,"y":362,"deltaY":-5},{"type":"mousewheel","time":12285,"x":458,"y":362,"deltaY":-1},{"type":"mousemove","time":12425,"x":463,"y":361},{"type":"mousemove","time":12628,"x":674,"y":304},{"type":"mousemove","time":12834,"x":687,"y":288},{"type":"mousemove","time":13066,"x":690,"y":286},{"type":"mousedown","time":13110,"x":690,"y":286},{"type":"mouseup","time":13324,"x":690,"y":286},{"time":13325,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13410,"x":689,"y":286},{"type":"mousemove","time":13628,"x":395,"y":352},{"type":"mousemove","time":13854,"x":300,"y":354},{"type":"mousemove","time":14063,"x":345,"y":354},{"type":"mousedown","time":14279,"x":345,"y":354},{"type":"mousemove","time":14291,"x":348,"y":354},{"type":"mousemove","time":14495,"x":665,"y":352},{"type":"mousemove","time":14697,"x":675,"y":351},{"type":"mouseup","time":14765,"x":675,"y":351},{"time":14766,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14904,"x":448,"y":419},{"type":"mousemove","time":15114,"x":448,"y":419},{"type":"mousemove","time":15318,"x":460,"y":406},{"type":"mousemove","time":15520,"x":411,"y":360},{"type":"mousemove","time":15732,"x":342,"y":361},{"type":"mousemove","time":15944,"x":312,"y":363},{"type":"mousedown","time":16196,"x":312,"y":363},{"type":"mousemove","time":16206,"x":318,"y":361},{"type":"mousemove","time":16415,"x":541,"y":351},{"type":"mousemove","time":16618,"x":707,"y":364},{"type":"mousemove","time":16830,"x":762,"y":359},{"type":"mousemove","time":17045,"x":780,"y":352},{"type":"mouseup","time":17178,"x":780,"y":352},{"time":17179,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":17197,"x":736,"y":353},{"type":"mousemove","time":17400,"x":575,"y":353},{"type":"mousemove","time":17601,"x":506,"y":361},{"type":"mousemove","time":17811,"x":500,"y":361},{"type":"mousewheel","time":18101,"x":500,"y":361,"deltaY":-1},{"type":"mousewheel","time":18131,"x":500,"y":361,"deltaY":-32},{"type":"mousewheel","time":18155,"x":500,"y":361,"deltaY":-105},{"type":"mousewheel","time":18183,"x":500,"y":361,"deltaY":-37},{"type":"mousewheel","time":18208,"x":500,"y":361,"deltaY":-30},{"type":"mousewheel","time":18240,"x":500,"y":361,"deltaY":-60},{"type":"mousewheel","time":18265,"x":500,"y":361,"deltaY":-285},{"type":"mousewheel","time":18292,"x":500,"y":361,"deltaY":-137},{"type":"mousewheel","time":18315,"x":500,"y":361,"deltaY":-66},{"type":"mousewheel","time":18347,"x":500,"y":361,"deltaY":-120},{"type":"mousewheel","time":18371,"x":500,"y":361,"deltaY":-104},{"type":"mousewheel","time":18401,"x":500,"y":361,"deltaY":-47},{"type":"mousewheel","time":18424,"x":500,"y":361,"deltaY":-82},{"type":"mousewheel","time":18457,"x":500,"y":361,"deltaY":-71},{"type":"mousewheel","time":18481,"x":500,"y":361,"deltaY":-31},{"type":"mousewheel","time":18505,"x":500,"y":361,"deltaY":-54},{"type":"mousewheel","time":18530,"x":500,"y":361,"deltaY":-24},{"type":"mousewheel","time":18554,"x":500,"y":361,"deltaY":-41},{"type":"mousewheel","time":18586,"x":500,"y":361,"deltaY":-18},{"type":"mousewheel","time":18613,"x":500,"y":361,"deltaY":-31},{"type":"mousewheel","time":18642,"x":500,"y":361,"deltaY":-27},{"type":"mousewheel","time":18667,"x":500,"y":361,"deltaY":-12},{"type":"mousewheel","time":18692,"x":500,"y":361,"deltaY":-19},{"type":"mousewheel","time":18718,"x":500,"y":361,"deltaY":-9},{"type":"mousewheel","time":18742,"x":500,"y":361,"deltaY":-15},{"type":"mousewheel","time":18767,"x":500,"y":361,"deltaY":-7},{"type":"mousewheel","time":18793,"x":500,"y":361,"deltaY":-12},{"type":"mousewheel","time":18822,"x":500,"y":361,"deltaY":-5},{"type":"mousewheel","time":18848,"x":500,"y":361,"deltaY":-10},{"type":"mousewheel","time":18875,"x":500,"y":361,"deltaY":-8},{"type":"mousewheel","time":18901,"x":500,"y":361,"deltaY":-4},{"type":"mousewheel","time":18925,"x":500,"y":361,"deltaY":-6},{"type":"mousewheel","time":18949,"x":500,"y":361,"deltaY":-3},{"type":"mousewheel","time":18973,"x":500,"y":361,"deltaY":-5},{"type":"mousewheel","time":18997,"x":500,"y":361,"deltaY":-2},{"type":"mousewheel","time":19023,"x":500,"y":361,"deltaY":-4},{"type":"mousewheel","time":19045,"x":500,"y":361,"deltaY":-1},{"type":"mousewheel","time":19068,"x":500,"y":361,"deltaY":-1},{"type":"mousewheel","time":19092,"x":500,"y":361,"deltaY":-2},{"type":"mousewheel","time":19119,"x":500,"y":361,"deltaY":-1},{"type":"mousewheel","time":19143,"x":500,"y":361,"deltaY":-1},{"type":"mousewheel","time":19168,"x":500,"y":361,"deltaY":-1},{"type":"mousewheel","time":19192,"x":500,"y":361,"deltaY":-1},{"type":"mousedown","time":19364,"x":500,"y":361},{"type":"mousemove","time":19375,"x":504,"y":361},{"type":"mousemove","time":19580,"x":520,"y":357},{"type":"mouseup","time":19965,"x":520,"y":357},{"time":19966,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":19989,"x":468,"y":393},{"type":"mousemove","time":20199,"x":457,"y":415},{"type":"mousemove","time":20413,"x":465,"y":414},{"type":"mousemove","time":20618,"x":507,"y":385},{"type":"mousemove","time":20829,"x":630,"y":343},{"type":"mousemove","time":21042,"x":676,"y":335},{"type":"mousemove","time":21248,"x":619,"y":303},{"type":"mousemove","time":21464,"x":618,"y":303},{"type":"mousedown","time":21629,"x":618,"y":303},{"type":"mouseup","time":21745,"x":618,"y":303},{"time":21746,"delay":400,"type":"screenshot-auto"}],"scrollY":1239,"scrollX":0,"timestamp":1767862514414},{"name":"Action 5","ops":[{"type":"mousemove","time":101,"x":614,"y":315},{"type":"mousemove","time":301,"x":559,"y":324},{"type":"mousemove","time":502,"x":249,"y":289},{"type":"mousemove","time":710,"x":228,"y":279},{"type":"mousemove","time":918,"x":175,"y":257},{"type":"mousedown","time":1110,"x":164,"y":250},{"type":"mousemove","time":1128,"x":164,"y":250},{"type":"mouseup","time":1210,"x":164,"y":250},{"time":1211,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1285,"x":164,"y":250},{"type":"mousemove","time":1485,"x":191,"y":250},{"type":"mousedown","time":1677,"x":212,"y":252},{"type":"mousemove","time":1686,"x":212,"y":252},{"type":"mouseup","time":1777,"x":212,"y":252},{"time":1778,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1896,"x":239,"y":252},{"type":"mousemove","time":2101,"x":393,"y":254},{"type":"mousemove","time":2302,"x":421,"y":250},{"type":"mousedown","time":2344,"x":421,"y":250},{"type":"mouseup","time":2410,"x":421,"y":250},{"time":2411,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2512,"x":421,"y":250},{"type":"mousemove","time":2618,"x":425,"y":250},{"type":"mousemove","time":2818,"x":515,"y":260},{"type":"mousedown","time":3011,"x":530,"y":260},{"type":"mousemove","time":3029,"x":530,"y":260},{"type":"mouseup","time":3127,"x":530,"y":260},{"time":3128,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3351,"x":534,"y":260},{"type":"mousemove","time":3551,"x":660,"y":249},{"type":"mousemove","time":3760,"x":675,"y":249},{"type":"mousedown","time":3828,"x":676,"y":249},{"type":"mouseup","time":3910,"x":676,"y":249},{"time":3911,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3978,"x":676,"y":249},{"type":"mousemove","time":4135,"x":675,"y":249},{"type":"mousemove","time":4345,"x":458,"y":311},{"type":"mousemove","time":4552,"x":453,"y":300},{"type":"mousedown","time":4712,"x":453,"y":298},{"type":"mousemove","time":4764,"x":453,"y":298},{"type":"mouseup","time":4812,"x":453,"y":298},{"time":4813,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5019,"x":452,"y":300},{"type":"mousemove","time":5220,"x":439,"y":335},{"type":"mousedown","time":5395,"x":437,"y":340},{"type":"mousemove","time":5429,"x":437,"y":340},{"type":"mouseup","time":5478,"x":437,"y":340},{"time":5479,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5702,"x":437,"y":343},{"type":"mousemove","time":5913,"x":429,"y":376},{"type":"mousedown","time":6061,"x":429,"y":376},{"type":"mousemove","time":6128,"x":429,"y":376},{"type":"mouseup","time":6162,"x":429,"y":376},{"time":6163,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6501,"x":430,"y":377},{"type":"mousemove","time":6701,"x":646,"y":467},{"type":"mousemove","time":6902,"x":623,"y":460},{"type":"mousemove","time":7102,"x":580,"y":453},{"type":"mousemove","time":7305,"x":668,"y":445},{"type":"mousemove","time":7512,"x":675,"y":435},{"type":"mousemove","time":7718,"x":688,"y":427},{"type":"mousedown","time":7814,"x":688,"y":427},{"type":"mouseup","time":7928,"x":688,"y":427},{"time":7929,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7971,"x":688,"y":427},{"type":"mousemove","time":8185,"x":515,"y":504},{"type":"mousemove","time":8385,"x":456,"y":489},{"type":"mousemove","time":8596,"x":449,"y":488},{"type":"mousedown","time":8779,"x":449,"y":488},{"type":"mousemove","time":8789,"x":449,"y":488},{"type":"mousemove","time":8999,"x":579,"y":504},{"type":"mouseup","time":9214,"x":583,"y":505},{"time":9215,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9233,"x":571,"y":506},{"type":"mousemove","time":9434,"x":471,"y":507},{"type":"mousemove","time":9635,"x":391,"y":501},{"type":"mousemove","time":9846,"x":381,"y":498},{"type":"mousedown","time":9998,"x":381,"y":498},{"type":"mousemove","time":10009,"x":383,"y":498},{"type":"mousemove","time":10216,"x":488,"y":501},{"type":"mousemove","time":10430,"x":508,"y":497},{"type":"mouseup","time":10483,"x":508,"y":497},{"time":10484,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10503,"x":563,"y":508},{"type":"mousemove","time":10714,"x":631,"y":514},{"type":"mousemove","time":10885,"x":633,"y":513},{"type":"mousemove","time":11085,"x":670,"y":493},{"type":"mousemove","time":11285,"x":718,"y":462},{"type":"mousemove","time":11485,"x":727,"y":451},{"type":"mousemove","time":11685,"x":722,"y":432},{"type":"mousedown","time":11895,"x":721,"y":422},{"type":"mousemove","time":11916,"x":721,"y":422},{"type":"mouseup","time":11979,"x":721,"y":422},{"time":11980,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":12247,"x":721,"y":422},{"type":"mouseup","time":12364,"x":721,"y":422},{"time":12365,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":12480,"x":721,"y":422},{"type":"mouseup","time":12580,"x":721,"y":422},{"time":12581,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13269,"x":721,"y":423},{"type":"mousemove","time":13496,"x":712,"y":433}],"scrollY":1600,"scrollX":0,"timestamp":1767784083545},{"name":"Action 6","ops":[{"type":"mousemove","time":615,"x":732,"y":262},{"type":"mousemove","time":824,"x":278,"y":202},{"type":"mousemove","time":1031,"x":167,"y":194},{"type":"mousemove","time":1241,"x":145,"y":192},{"type":"mousedown","time":1253,"x":145,"y":192},{"type":"mouseup","time":1373,"x":145,"y":192},{"time":1374,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1497,"x":147,"y":192},{"type":"mousemove","time":1697,"x":189,"y":192},{"type":"mousedown","time":1863,"x":216,"y":193},{"type":"mousemove","time":1926,"x":216,"y":193},{"type":"mouseup","time":1974,"x":216,"y":193},{"time":1975,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2131,"x":264,"y":196},{"type":"mousemove","time":2331,"x":345,"y":200},{"type":"mousemove","time":2531,"x":394,"y":200},{"type":"mousedown","time":2641,"x":398,"y":200},{"type":"mouseup","time":2725,"x":398,"y":200},{"time":2726,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2764,"x":398,"y":200},{"type":"mousemove","time":2897,"x":400,"y":200},{"type":"mousemove","time":3098,"x":519,"y":192},{"type":"mousemove","time":3307,"x":555,"y":186},{"type":"mousedown","time":3341,"x":555,"y":186},{"type":"mouseup","time":3425,"x":555,"y":186},{"time":3426,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3581,"x":553,"y":187},{"type":"mousemove","time":3781,"x":357,"y":205},{"type":"mousemove","time":3981,"x":139,"y":237},{"type":"mousedown","time":4146,"x":133,"y":239},{"type":"mousemove","time":4191,"x":133,"y":239},{"type":"mouseup","time":4274,"x":133,"y":239},{"time":4275,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4397,"x":195,"y":239},{"type":"mousemove","time":4597,"x":272,"y":235},{"type":"mousedown","time":4651,"x":276,"y":235},{"type":"mouseup","time":4764,"x":276,"y":235},{"time":4765,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4819,"x":276,"y":235},{"type":"mousemove","time":5031,"x":231,"y":265},{"type":"mousedown","time":5227,"x":224,"y":273},{"type":"mousemove","time":5267,"x":224,"y":273},{"type":"mouseup","time":5317,"x":224,"y":273},{"time":5318,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5386,"x":224,"y":273},{"type":"mousemove","time":5594,"x":317,"y":273},{"type":"mousemove","time":5797,"x":368,"y":273},{"type":"mousedown","time":5980,"x":381,"y":273},{"type":"mousemove","time":6027,"x":381,"y":273},{"type":"mouseup","time":6066,"x":381,"y":273},{"time":6067,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6414,"x":381,"y":275},{"type":"mousemove","time":6628,"x":380,"y":424},{"type":"mousemove","time":6829,"x":380,"y":425},{"type":"mousewheel","time":7247,"x":380,"y":425,"deltaY":-1},{"type":"mousewheel","time":7279,"x":380,"y":425,"deltaY":-2},{"type":"mousewheel","time":7304,"x":380,"y":425,"deltaY":-6},{"type":"mousewheel","time":7332,"x":380,"y":425,"deltaY":-13},{"type":"mousewheel","time":7357,"x":380,"y":425,"deltaY":-6},{"type":"mousewheel","time":7392,"x":380,"y":425,"deltaY":-11},{"type":"mousewheel","time":7419,"x":380,"y":425,"deltaY":-12},{"type":"mousewheel","time":7448,"x":380,"y":425,"deltaY":-11},{"type":"mousewheel","time":7474,"x":380,"y":425,"deltaY":-6},{"type":"mousewheel","time":7512,"x":380,"y":425,"deltaY":-11},{"type":"mousewheel","time":7540,"x":380,"y":425,"deltaY":-14},{"type":"mousewheel","time":7567,"x":380,"y":425,"deltaY":-14},{"type":"mousewheel","time":7595,"x":380,"y":425,"deltaY":-14},{"type":"mousewheel","time":7632,"x":380,"y":425,"deltaY":-15},{"type":"mousewheel","time":7660,"x":380,"y":425,"deltaY":-6},{"type":"mousewheel","time":7687,"x":380,"y":425,"deltaY":-5},{"type":"mousewheel","time":7722,"x":380,"y":425,"deltaY":-7},{"type":"mousewheel","time":7756,"x":380,"y":425,"deltaY":-28},{"type":"mousewheel","time":7783,"x":380,"y":425,"deltaY":-15},{"type":"mousewheel","time":7811,"x":380,"y":425,"deltaY":-7},{"type":"mousewheel","time":7841,"x":380,"y":425,"deltaY":-12},{"type":"mousewheel","time":7868,"x":380,"y":425,"deltaY":-10},{"type":"mousewheel","time":7895,"x":380,"y":425,"deltaY":-9},{"type":"mousewheel","time":7920,"x":380,"y":425,"deltaY":-4},{"type":"mousewheel","time":7944,"x":380,"y":425,"deltaY":-7},{"type":"mousewheel","time":7973,"x":380,"y":425,"deltaY":-3},{"type":"mousewheel","time":7998,"x":380,"y":425,"deltaY":-6},{"type":"mousewheel","time":8023,"x":380,"y":425,"deltaY":-4},{"type":"mousewheel","time":8047,"x":380,"y":425,"deltaY":-2},{"type":"mousewheel","time":8072,"x":380,"y":425,"deltaY":-2},{"type":"mousewheel","time":8100,"x":380,"y":425,"deltaY":-2},{"type":"mousewheel","time":8124,"x":380,"y":425,"deltaY":-1},{"type":"mousewheel","time":8214,"x":380,"y":425,"deltaY":-1},{"type":"mousewheel","time":8238,"x":380,"y":425,"deltaY":-1},{"type":"mousewheel","time":8264,"x":380,"y":425,"deltaY":-5},{"type":"mousewheel","time":8289,"x":380,"y":425,"deltaY":-2},{"type":"mousewheel","time":8317,"x":380,"y":425,"deltaY":-2},{"type":"mousewheel","time":8341,"x":380,"y":425,"deltaY":-1},{"type":"mousewheel","time":8580,"x":380,"y":425,"deltaY":1},{"type":"mousewheel","time":8614,"x":380,"y":425,"deltaY":7},{"type":"mousewheel","time":8641,"x":380,"y":425,"deltaY":11},{"type":"mousewheel","time":8673,"x":380,"y":425,"deltaY":50},{"type":"mousewheel","time":8701,"x":380,"y":425,"deltaY":47},{"type":"mousewheel","time":8736,"x":380,"y":425,"deltaY":19},{"type":"mousewheel","time":8765,"x":380,"y":425,"deltaY":31},{"type":"mousewheel","time":8793,"x":380,"y":425,"deltaY":145},{"type":"mousewheel","time":8830,"x":380,"y":425,"deltaY":73},{"type":"mousewheel","time":8862,"x":380,"y":425,"deltaY":72},{"type":"mousewheel","time":8889,"x":380,"y":425,"deltaY":61},{"type":"mousewheel","time":8938,"x":380,"y":425,"deltaY":2},{"type":"mousewheel","time":8971,"x":380,"y":425,"deltaY":6},{"type":"mousewheel","time":8999,"x":380,"y":425,"deltaY":40},{"type":"mousewheel","time":9026,"x":380,"y":425,"deltaY":33},{"type":"mousewheel","time":9054,"x":380,"y":425,"deltaY":72},{"type":"mousewheel","time":9088,"x":380,"y":425,"deltaY":56},{"type":"mousewheel","time":9117,"x":380,"y":425,"deltaY":38},{"type":"mousewheel","time":9149,"x":380,"y":425,"deltaY":33},{"type":"mousewheel","time":9188,"x":380,"y":425,"deltaY":155},{"type":"mousewheel","time":9219,"x":380,"y":425,"deltaY":38},{"type":"mousewheel","time":9246,"x":380,"y":425,"deltaY":82},{"type":"mousewheel","time":9273,"x":380,"y":425,"deltaY":71},{"type":"mousewheel","time":9311,"x":380,"y":425,"deltaY":59},{"type":"mousewheel","time":9338,"x":380,"y":425,"deltaY":50},{"type":"mousewheel","time":9370,"x":380,"y":425,"deltaY":22},{"type":"mousewheel","time":9402,"x":380,"y":425,"deltaY":39},{"type":"mousewheel","time":9434,"x":380,"y":425,"deltaY":33},{"type":"mousewheel","time":9462,"x":380,"y":425,"deltaY":14},{"type":"mousemove","time":9492,"x":380,"y":424},{"type":"mousemove","time":9705,"x":471,"y":449},{"type":"mousemove","time":9917,"x":729,"y":391},{"type":"mousemove","time":10130,"x":724,"y":353},{"type":"mousemove","time":10337,"x":687,"y":353},{"type":"mousemove","time":10547,"x":688,"y":341},{"type":"mousemove","time":10759,"x":588,"y":415},{"type":"mousewheel","time":10957,"x":586,"y":416,"deltaY":-1},{"type":"mousewheel","time":10991,"x":586,"y":416,"deltaY":-4},{"type":"mousemove","time":11021,"x":586,"y":416},{"type":"mousewheel","time":11033,"x":586,"y":416,"deltaY":-4},{"type":"mousewheel","time":11062,"x":586,"y":416,"deltaY":-4},{"type":"mousewheel","time":11099,"x":586,"y":416,"deltaY":-3},{"type":"mousewheel","time":11127,"x":586,"y":416,"deltaY":0},{"type":"mousewheel","time":11157,"x":586,"y":416,"deltaY":-2},{"type":"mousewheel","time":11241,"x":586,"y":416,"deltaY":-1},{"type":"mousewheel","time":11271,"x":586,"y":416,"deltaY":-1},{"type":"mousewheel","time":11343,"x":586,"y":416,"deltaY":-1},{"type":"mousemove","time":11504,"x":586,"y":414},{"type":"mousemove","time":11705,"x":640,"y":347},{"type":"mousemove","time":11908,"x":696,"y":327},{"type":"mousemove","time":12135,"x":701,"y":325},{"type":"mousemove","time":12369,"x":694,"y":328},{"type":"mousedown","time":12386,"x":694,"y":328},{"type":"mouseup","time":12495,"x":694,"y":328},{"time":12496,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12562,"x":687,"y":330},{"type":"mousemove","time":12763,"x":536,"y":406},{"type":"mousemove","time":12982,"x":529,"y":408},{"type":"mousedown","time":13167,"x":529,"y":408},{"type":"mousemove","time":13180,"x":531,"y":408},{"type":"mousemove","time":13381,"x":540,"y":408},{"type":"mousemove","time":13607,"x":540,"y":408},{"type":"mouseup","time":13666,"x":540,"y":408},{"time":13667,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13687,"x":528,"y":427},{"type":"mousemove","time":13897,"x":503,"y":473},{"type":"mousemove","time":14114,"x":507,"y":473},{"type":"mousemove","time":14331,"x":507,"y":473},{"type":"mousemove","time":14534,"x":530,"y":470},{"type":"mousemove","time":14734,"x":623,"y":449},{"type":"mousemove","time":14940,"x":756,"y":337},{"type":"mousemove","time":15155,"x":748,"y":278},{"type":"mousedown","time":15218,"x":745,"y":274},{"type":"mouseup","time":15330,"x":745,"y":274},{"time":15331,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15381,"x":745,"y":274}],"scrollY":2138.5,"scrollX":0,"timestamp":1767862547101}] \ No newline at end of file From d013d5e397b75b40a2e5a9588b701fb16ca927cf Mon Sep 17 00:00:00 2001 From: 100pah Date: Thu, 8 Jan 2026 16:59:03 +0800 Subject: [PATCH 03/31] timeScale: Add the missing rounding (broken from a previous version) to time scale. --- src/scale/Time.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scale/Time.ts b/src/scale/Time.ts index a3a7de479c..eb682440c9 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -306,8 +306,8 @@ class TimeScale extends IntervalScale { } parse(val: number | string | Date): number { - // val might be float. - return isNumber(val) ? val : +numberUtil.parseDate(val); + // `val` might be a float (e.g., calculated from percent), so call `round`. + return isNumber(val) ? Math.round(val) : +numberUtil.parseDate(val); } contain(val: number): boolean { From 479dcd454f0f49c70177284a67c490d624bef0c3 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 9 Jan 2026 16:24:14 +0800 Subject: [PATCH 04/31] fix&feat: Change and clarify the rounding error and auto-precision utils and solutions. --- src/scale/helper.ts | 144 ++++++++++++++++++++----- src/util/number.ts | 177 ++++++++++++++++++++++++------- test/ut/spec/util/number.test.ts | 74 ++++++++++++- 3 files changed, 332 insertions(+), 63 deletions(-) diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 31f41945ec..c5143d5309 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -17,12 +17,14 @@ * under the License. */ -import {getPrecision, round, nice, quantityExponent} from '../util/number'; +import {getPrecision, round, nice, quantityExponent, mathPow, mathMax, mathRound} from '../util/number'; import IntervalScale from './Interval'; import LogScale from './Log'; import type Scale from './Scale'; import { bind } from 'zrender/src/core/util'; import type { ScaleBreakContext } from './break'; +import TimeScale from './Time'; +import { NullUndefined } from '../util/types'; type intervalScaleNiceTicksResult = { interval: number, @@ -30,19 +32,39 @@ type intervalScaleNiceTicksResult = { niceTickExtent: [number, number] }; -export function isValueNice(val: number) { - const exp10 = Math.pow(10, quantityExponent(Math.abs(val))); - const f = Math.abs(val / exp10); - return f === 0 - || f === 1 - || f === 2 - || f === 3 - || f === 5; -} +/** + * See also method `nice` in `src/util/number.ts`. + */ +// export function isValueNice(val: number) { +// const exp10 = Math.pow(10, quantityExponent(Math.abs(val))); +// const f = Math.abs(round(val / exp10, 0)); +// return f === 0 +// || f === 1 +// || f === 2 +// || f === 3 +// || f === 5; +// } export function isIntervalOrLogScale(scale: Scale): scale is LogScale | IntervalScale { - return scale.type === 'interval' || scale.type === 'log'; + return isIntervalScale(scale) || isLogScale(scale); +} + +export function isIntervalScale(scale: Scale): scale is IntervalScale { + return scale.type === 'interval'; } + +export function isTimeScale(scale: Scale): scale is TimeScale { + return scale.type === 'time'; +} + +export function isLogScale(scale: Scale): scale is LogScale { + return scale.type === 'log'; +} + +export function isOrdinalScale(scale: Scale): boolean { + return scale.type === 'ordinal'; +} + /** * @param extent Both extent[0] and extent[1] should be valid number. * Should be extent[0] < extent[1]. @@ -65,7 +87,6 @@ export function intervalScaleNiceTicks( if (maxInterval != null && interval > maxInterval) { interval = result.interval = maxInterval; } - // Tow more digital for tick. const precision = result.intervalPrecision = getIntervalPrecision(interval); // Niced extent inside original extent const niceTickExtent = result.niceTickExtent = [ @@ -73,15 +94,22 @@ export function intervalScaleNiceTicks( round(Math.floor(extent[1] / interval) * interval, precision) ]; - fixExtent(niceTickExtent, extent); + fixNiceExtent(niceTickExtent, extent); return result; } -export function increaseInterval(interval: number) { - const exp10 = Math.pow(10, quantityExponent(interval)); - // Increase interval - let f = interval / exp10; +/** + * The input `niceInterval` should be generated + * from `nice` method in `src/util/number.ts`, or + * from `increaseInterval` itself. + */ +export function increaseInterval(niceInterval: number) { + const exponent = quantityExponent(niceInterval); + // No rounding error in Math.pow(10, xxx). + const exp10 = mathPow(10, exponent); + // Fix IEEE 754 float rounding error + let f = mathRound(niceInterval / exp10); if (!f) { f = 1; } @@ -94,17 +122,18 @@ export function increaseInterval(interval: number) { else { // f is 1 or 5 f *= 2; } - return round(f * exp10); + // Fix IEEE 754 float rounding error + return round(f * exp10, -exponent); } -/** - * @return interval precision - */ -export function getIntervalPrecision(interval: number): number { +export function getIntervalPrecision(niceInterval: number): number { // Tow more digital for tick. - return getPrecision(interval) + 2; + // NOTE: `2` was introduced in commit `af2a2a9f6303081d7c3b52f0a38add07b4c6e0c7`; + // it works on "nice" interval, but seems not necessarily mathematically required. + return getPrecision(niceInterval) + 2; } + function clamp( niceTickExtent: [number, number], idx: number, extent: [number, number] ): void { @@ -112,7 +141,7 @@ function clamp( } // In some cases (e.g., splitNumber is 1), niceTickExtent may be out of extent. -export function fixExtent( +export function fixNiceExtent( niceTickExtent: [number, number], extent: [number, number] ): void { !isFinite(niceTickExtent[0]) && (niceTickExtent[0] = extent[0]); @@ -173,3 +202,70 @@ export function logTransform(base: number, extent: number[], noClampNegative?: b Math.log(noClampNegative ? extent[1] : Math.max(0, extent[1])) / loggedBase ]; } + +export function powTransform(base: number, extent: number[]): [number, number] { + return [ + mathPow(base, extent[0]), + mathPow(base, extent[1]) + ]; +} + +/** + * A valid extent is: + * - No non-finite number. + * - `extent[0] < extent[1]`. + * + * [NOTICE]: The input `rawExtent` can only be: + * - All non-finite numbers or `NaN`; or + * - `[Infinity, -Infinity]` (A typical initial extent with no data.) + * (Improve it when needed.) + */ +export function intervalScaleEnsureValidExtent( + rawExtent: number[], + opt: { + fixMax?: boolean + } +): number[] { + const extent = rawExtent.slice(); + // If extent start and end are same, expand them + if (extent[0] === extent[1]) { + if (extent[0] !== 0) { + // Expand extent + // Note that extents can be both negative. See #13154 + const expandSize = Math.abs(extent[0]); + // In the fowllowing case + // Axis has been fixed max 100 + // Plus data are all 100 and axis extent are [100, 100]. + // Extend to the both side will cause expanded max is larger than fixed max. + // So only expand to the smaller side. + if (!opt.fixMax) { + extent[1] += expandSize / 2; + extent[0] -= expandSize / 2; + } + else { + extent[0] -= expandSize / 2; + } + } + else { + extent[1] = 1; + } + } + const span = extent[1] - extent[0]; + // If there are no data and extent are [Infinity, -Infinity] + if (!isFinite(span)) { + extent[0] = 0; + extent[1] = 1; + } + else if (span < 0) { + extent.reverse(); + } + + return extent; +} + +export function ensureValidSplitNumber( + rawSplitNumber: number | NullUndefined, defaultSplitNumber: number +): number { + rawSplitNumber = rawSplitNumber || defaultSplitNumber; + return mathRound(mathMax(rawSplitNumber, 1)); +} diff --git a/src/util/number.ts b/src/util/number.ts index f156e6d3de..0743743516 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -27,11 +27,14 @@ */ import * as zrUtil from 'zrender/src/core/util'; +import { NullUndefined } from './types'; const RADIAN_EPSILON = 1e-4; -// Although chrome already enlarge this number to 100 for `toFixed`, but -// we sill follow the spec for compatibility. -const ROUND_SUPPORTED_PRECISION_MAX = 20; + +// A `RangeError` may be thrown if `n` is out of this range when calling `toFixed(n)`. +// Although Chrome and ES2017+ have enlarged this number to 100, but we sill follow +// the ES3~ES6 spec (0 <= n <= 20) for backward and cross-platform compatibility. +const TO_FIXED_SUPPORTED_PRECISION_MAX = 20; function _trim(str: string): string { return str.replace(/^\s+|\s+$/g, ''); @@ -40,6 +43,14 @@ function _trim(str: string): string { export const mathMin = Math.min; export const mathMax = Math.max; export const mathAbs = Math.abs; +export const mathRound = Math.round; +export const mathFloor = Math.floor; +export const mathCeil = Math.ceil; +export const mathPow = Math.pow; +export const mathLog = Math.log; +export const mathLN10 = Math.LN10; +export const mathPI = Math.PI; +export const mathRandom = Math.random; /** * Linear mapping a value from domain to range @@ -149,8 +160,35 @@ export function parsePositionSizeOption(option: unknown, percentBase: number, pe } /** - * (1) Fix rounding error of float numbers. - * (2) Support return string to avoid scientific notation like '3.5e-7'. + * [Feature_1] Round at specified precision. + * FIXME: this is not a general-purpose rounding implementation yet due to `TO_FIXED_SUPPORTED_PRECISION_MAX`. + * e.g., `round(1.25 * 1e-150, 151)` has no overflow in IEEE754 64bit float, but can not be handled by + * this method. + * + * [Feature_2] Support return string to avoid scientific notation like '3.5e-7'. + * + * [Feature_3] Fix rounding error of float numbers !!!ONLY SUITABLE FOR SPECIAL CASES!!!. + * [CAVEAT]: + * Rounding is NEVER a general-purpose solution for rounding errors. + * Consider a case: `expect=123.99994999`, `actual=123.99995000` (suppose rounding error occurs). + * Calling `round(expect, 4)` gets `123.9999`. + * Calling `round(actual, 4)` gets `124.0000`. + * A unacceptable result arises, even if the original difference is only `0.00000001` (tiny + * and not strongly correlated with the digit pattern). + * So the rounding approach works only if: + * The digit next to the `precision` won't cross the rounding boundary. Typically, it works if + * the digit next to the `precision` is expected to be `0`, and the rounding error is small + * enough and impossible to affect that digit (`roundingError < Math.pow(10, -precision) / 2`). + * The quantity of a rounding error can be roughly estimated by formula: + * `minPrecisionRoundingErrorMayOccur ~= max(0, floor(14 - quantityExponent(val)))` + * MEMO: This is derived from: + * Let ` EXP52B10 = log10(pow(2, 52)) = 15.65355977452702 ` + * (`52` is IEEE754 float64 mantissa bits count) + * We require: ` abs(val) * pow(10, precision) < pow(10, EXP52B10) ` + * Hence: ` precision < EXP52B10 - log10(abs(val)) ` + * Hence: ` precision = floor( EXP52B10 - log10(abs(val)) ) ` + * Since: ` quantityExponent(val) = floor(log10(abs(val))) ` + * Hence: ` precision ~= floor(EXP52B10 - 1 - quantityExponent(val)) */ export function round(x: number | string, precision?: number): number; export function round(x: number | string, precision: number, returnStr: false): number; @@ -162,7 +200,7 @@ export function round(x: number | string, precision?: number, returnStr?: boolea precision = 10; } // Avoid range error - precision = Math.min(Math.max(0, precision), ROUND_SUPPORTED_PRECISION_MAX); + precision = mathMin(mathMax(0, precision), TO_FIXED_SUPPORTED_PRECISION_MAX); // PENDING: 1.005.toFixed(2) is '1.00' rather than '1.01' x = (+x).toFixed(precision); return (returnStr ? x : +x); @@ -181,6 +219,8 @@ export function asc(arr: T): T { /** * Get precision. + * e.g. `getPrecisionSafe(100.123)` return `3`. + * e.g. `getPrecisionSafe(100)` return `0`. */ export function getPrecision(val: string | number): number { val = +val; @@ -200,7 +240,7 @@ export function getPrecision(val: string | number): number { if (val > 1e-14) { let e = 1; for (let i = 0; i < 15; i++, e *= 10) { - if (Math.round(val * e) / e === val) { + if (mathRound(val * e) / e === val) { return i; } } @@ -211,6 +251,8 @@ export function getPrecision(val: string | number): number { /** * Get precision with slow but safe method + * e.g. `getPrecisionSafe(100.123)` return `3`. + * e.g. `getPrecisionSafe(100)` return `0`. */ export function getPrecisionSafe(val: string | number): number { // toLowerCase for: '3.4E-12' @@ -222,20 +264,57 @@ export function getPrecisionSafe(val: string | number): number { const significandPartLen = eIndex > 0 ? eIndex : str.length; const dotIndex = str.indexOf('.'); const decimalPartLen = dotIndex < 0 ? 0 : significandPartLen - 1 - dotIndex; - return Math.max(0, decimalPartLen - exp); + return mathMax(0, decimalPartLen - exp); } /** - * Minimal dicernible data precisioin according to a single pixel. + * @deprecated Use `getAcceptableTickPrecision` instead. See bad case in `test/ut/spec/util/number.test.ts` + * NOTE: originally introduced in commit `ff93e3e7f9ff24902e10d4469fd3187393b05feb` + * + * Minimal discernible data precision according to a single pixel. */ export function getPixelPrecision(dataExtent: [number, number], pixelExtent: [number, number]): number { - const log = Math.log; - const LN10 = Math.LN10; - const dataQuantity = Math.floor(log(dataExtent[1] - dataExtent[0]) / LN10); - const sizeQuantity = Math.round(log(mathAbs(pixelExtent[1] - pixelExtent[0])) / LN10); + const dataQuantity = mathFloor(mathLog(dataExtent[1] - dataExtent[0]) / mathLN10); + const sizeQuantity = mathRound(mathLog(mathAbs(pixelExtent[1] - pixelExtent[0])) / mathLN10); // toFixed() digits argument must be between 0 and 20. - const precision = Math.min(Math.max(-dataQuantity + sizeQuantity, 0), 20); - return !isFinite(precision) ? 20 : precision; + const precision = mathMin(mathMax(-dataQuantity + sizeQuantity, 0), TO_FIXED_SUPPORTED_PRECISION_MAX); + return !isFinite(precision) ? TO_FIXED_SUPPORTED_PRECISION_MAX : precision; +} + +/** + * This method chooses a reasonable "data" precision that can be used in `round` method. + * A reasonable precision is suitable for display; it may cause cumulative error but acceptable. + * + * "data" is linearly mapped to pixel according to the ratio determined by `dataSpan` and `pxSpan`. + * The diff from the original "data" to the rounded "data" (with the result precision) should be + * equal or less than `pxDiffAcceptable`, which is typically `1` pixel. + * And the result precision should be as small as possible. + * + * [NOTICE]: using arbitrary parameters is not preferable -- a discernible misalign (e.g., over 1px) + * may occur, especially when `splitLine` displayed. + */ +export function getAcceptableTickPrecision( + // Typically, `Math.abs(dataExtent[1] - dataExtent[0])`. + dataSpan: number, + // Typically, `Math.abs(pixelExtent[1] - pixelExtent[0])`. + pxSpan: number, + // By default, `1`. + pxDiffAcceptable: number | NullUndefined + // Return a precision >= 0 + // This precision can be used in method `round`. + // Return `NaN` for illegal inputs, such as `0`/`NaN`/`Infinity`. +): number { + // Formula for choosing an acceptable precision: + // Let `pxDiff = abs(dataSpan - round(dataSpan, precision))`. + // We require `pxDiff <= dataSpan * pxDiffAcceptable / pxSpan`. + // Consider the nature of "round", the max `pxDiff` is: `pow(10, -precision) / 2`, + // Hence: `pow(10, -precision) / 2 <= dataSpan * pxDiffAcceptable / pxSpan` + // Hence: `precision >= -log10(2 * dataSpan * pxDiffAcceptable / pxSpan)` + const dataExp2 = mathLog(2 * mathAbs(pxDiffAcceptable || 1) * mathAbs(dataSpan)) / mathLN10; + const pxExp = mathLog(mathAbs(pxSpan)) / mathLN10; + // PENDING: Rounding error generally does not matter; do not fix it before `Math.ceil` + // until bad case occur. + return mathMax(0, mathCeil(-dataExp2 + pxExp)); } /** @@ -277,7 +356,7 @@ export function getPercentSeats(valueList: number[], precision: number): number[ return []; } - const digits = Math.pow(10, precision); + const digits = mathPow(10, precision); const votesPerQuota = zrUtil.map(valueList, function (val) { return (isNaN(val) ? 0 : val) / sum * digits * 100; }); @@ -285,7 +364,7 @@ export function getPercentSeats(valueList: number[], precision: number): number[ const seats = zrUtil.map(votesPerQuota, function (votes) { // Assign automatic seats. - return Math.floor(votes); + return mathFloor(votes); }); let currentSum = zrUtil.reduce(seats, function (acc, val) { return acc + val; @@ -322,12 +401,12 @@ export function getPercentSeats(valueList: number[], precision: number): number[ * See */ export function addSafe(val0: number, val1: number): number { - const maxPrecision = Math.max(getPrecision(val0), getPrecision(val1)); + const maxPrecision = mathMax(getPrecision(val0), getPrecision(val1)); // const multiplier = Math.pow(10, maxPrecision); - // return (Math.round(val0 * multiplier) + Math.round(val1 * multiplier)) / multiplier; + // return (mathRound(val0 * multiplier) + mathRound(val1 * multiplier)) / multiplier; const sum = val0 + val1; // // PENDING: support more? - return maxPrecision > ROUND_SUPPORTED_PRECISION_MAX + return maxPrecision > TO_FIXED_SUPPORTED_PRECISION_MAX ? sum : round(sum, maxPrecision); } @@ -338,7 +417,7 @@ export const MAX_SAFE_INTEGER = 9007199254740991; * To 0 - 2 * PI, considering negative radian. */ export function remRadian(radian: number): number { - const pi2 = Math.PI * 2; + const pi2 = mathPI * 2; return (radian % pi2 + pi2) % pi2; } @@ -427,7 +506,7 @@ export function parseDate(value: unknown): Date { return new Date(NaN); } - return new Date(Math.round(value as number)); + return new Date(mathRound(value as number)); } /** @@ -437,50 +516,73 @@ export function parseDate(value: unknown): Date { * @return */ export function quantity(val: number): number { - return Math.pow(10, quantityExponent(val)); + return mathPow(10, quantityExponent(val)); } /** * Exponent of the quantity of a number - * e.g., 1234 equals to 1.234*10^3, so quantityExponent(1234) is 3 + * e.g., 9876 equals to 9.876*10^3, so quantityExponent(9876) is 3 + * e.g., 0.09876 equals to 9.876*10^-2, so quantityExponent(0.09876) is -2 * * @param val non-negative value * @return */ export function quantityExponent(val: number): number { if (val === 0) { + // PENDING: like IEEE754 use exponent `0` in this case. + // but methematically, exponent of zero is `-Infinity`. return 0; } - let exp = Math.floor(Math.log(val) / Math.LN10); + let exp = mathFloor(mathLog(val) / mathLN10); /** * exp is expected to be the rounded-down result of the base-10 log of val. * But due to the precision loss with Math.log(val), we need to restore it * using 10^exp to make sure we can get val back from exp. #11249 */ - if (val / Math.pow(10, exp) >= 10) { + if (val / mathPow(10, exp) >= 10) { exp++; } return exp; } +export const NICE_MODE_ROUND = 1 as const; +export const NICE_MODE_MIN = 2 as const; + /** - * find a “nice” number approximately equal to x. Round the number if round = true, - * take ceiling if round = false. The primary observation is that the “nicest” + * find a “nice” number approximately equal to x. Round the number if 'round', + * take ceiling if 'round'. The primary observation is that the “nicest” * numbers in decimal are 1, 2, and 5, and all power-of-ten multiples of these numbers. * * See "Nice Numbers for Graph Labels" of Graphic Gems. * * @param val Non-negative value. - * @param round * @return Niced number */ -export function nice(val: number, round?: boolean): number { +export function nice( + val: number, + // All non-`NICE_MODE_MIN`-truthy values means `NICE_MODE_ROUND`, for backward compatibility. + mode?: boolean | typeof NICE_MODE_ROUND | typeof NICE_MODE_MIN +): number { + // Consider the scientific notation of `val`: + // - `exponent` is its exponent. + // - `f` is its coefficient. `1 <= f < 10`. + // e.g., if `val` is `0.0054321`, `exponent` is `-3`, `f` is `5.4321`, + // The result is `0.005` on NICE_MODE_ROUND. + // e.g., if `val` is `987.12345`, `exponent` is `2`, `f` is `9.8712345`, + // The result is `1000` on NICE_MODE_ROUND. + // e.g., if `val` is `0`, + // The result is `1`. const exponent = quantityExponent(val); - const exp10 = Math.pow(10, exponent); - const f = val / exp10; // 1 <= f < 10 + // No rounding error in Math.pow(10, xxx). + const exp10 = mathPow(10, exponent); + const f = val / exp10; + let nf; - if (round) { + if (mode === NICE_MODE_MIN) { + nf = 1; + } + else if (mode) { if (f < 1.5) { nf = 1; } @@ -516,9 +618,8 @@ export function nice(val: number, round?: boolean): number { } val = nf * exp10; - // Fix 3 * 0.1 === 0.30000000000000004 issue (see IEEE 754). - // 20 is the uppper bound of toFixed. - return exponent >= -20 ? +val.toFixed(exponent < 0 ? -exponent : 0) : val; + // Fix IEEE 754 float rounding error + return round(val, -exponent); } /** @@ -529,7 +630,7 @@ export function nice(val: number, round?: boolean): number { */ export function quantile(ascArr: number[], p: number): number { const H = (ascArr.length - 1) * p + 1; - const h = Math.floor(H); + const h = mathFloor(H); const v = +ascArr[h - 1]; const e = H - h; return e ? v + e * (ascArr[h] - v) : v; @@ -640,7 +741,7 @@ export function isNumeric(val: unknown): val is number { * @return An positive integer. */ export function getRandomIdBase(): number { - return Math.round(Math.random() * 9); + return mathRound(mathRandom() * 9); } /** diff --git a/test/ut/spec/util/number.test.ts b/test/ut/spec/util/number.test.ts index 52d872130c..25bb4502a0 100755 --- a/test/ut/spec/util/number.test.ts +++ b/test/ut/spec/util/number.test.ts @@ -21,7 +21,9 @@ import { linearMap, parseDate, reformIntervals, getPrecisionSafe, getPrecision, getPercentWithPrecision, quantityExponent, quantity, nice, - isNumeric, numericToNumber, addSafe + isNumeric, numericToNumber, addSafe, + getPixelPrecision, + getAcceptablePrecision } from '@/src/util/number'; @@ -1015,4 +1017,74 @@ describe('util/number', function () { testNumeric(function () {}, NaN, false); }); + describe('getAcceptablePrecision', function () { + // NOTICE: These cases fail in `getPixelPrecision` (pxDiff1 > 1) + const CASES = [ + // dataExtent pixelExtent precision1 precision2 diff1 diff2 + [ [ 0, 1e-3 ], [ 0, 100 ] ], // 1 0 + [ [ 0, 1e5 ], [ 0, 100 ] ], // 1 0 + [ [ 0, 816.2050883836147 ], [ 0, 914.7923109827166 ] ], // 1 0 + [ [ 0, 132.4279201671552 ], [ 0, 267.9399859644955 ] ], // 0 1 1.011644620055545 0.1011644620055545 + [ [ 0, 100.34020279327427 ], [ 0, 287.77043437322726 ] ], // 0 1 1.433973753103259 0.14339737531032593 + [ [ 0, 131.76288568225613 ], [ 0, 268.9583525845105 ] ], // 0 1 1.020614990298174 0.10206149902981741 + [ [ 0, 100.28571954202148 ], [ 0, 256.9972613326965 ] ], // 0 1 1.2813253098563555 0.12813253098563557 + [ [ 0, 104.20905450687412 ], [ 0, 301.06863468545566 ] ], // 0 1 1.4445416288926973 0.14445416288926974 + [ [ 0, 0.0000012212958760328775 ], [ 0, 30.161832948821356 ] ], // 7 8 1.234829067252553 0.12348290672525532 fail1 + [ [ 0, 1.0169256034269881e-7 ], [ 0, 293.5116394339741 ] ], // 9 10 1.4431323119648805 0.14431323119648806 fail1 + [ [ 0, 0.0011105264071798859 ], [ 0, 222.30675252167865 ] ], // 5 6 1.0009070972306418 0.10009070972306418 fail1 + [ [ 0, 0.00010498610084514804 ], [ 0, 264.6383246939843 ] ], // 6 7 1.260349334643447 0.1260349334643447 fail1 + ]; + const NAN_CASES = [ + [ [ 0, 0 ], [ 0, 100 ] ], + ]; + + // We require `diff * pxSpan / dataSpan <= pxDiffAcceptable`. + // The max `diff` is: `pow(10, -precision) / 2`. + function calcMaxPxDiff(dataExtent: number[], pxExtent: number[], precision: number): number { + return Math.pow(10, -precision) / 2 + * Math.abs(pxExtent[1] - pxExtent[0]) + / Math.abs(dataExtent[1] - dataExtent[0]); + } + + for (let idx = 0; idx < CASES.length; idx++) { + const caseItem = CASES[idx]; + const dataExtent = caseItem[0]; + const pxExtent = caseItem[1]; + const precision1 = getPixelPrecision( + dataExtent.slice() as [number, number], + pxExtent.slice() as [number, number] + ); + const precision2 = getAcceptablePrecision( + dataExtent[1] - dataExtent[0], + pxExtent[1] - pxExtent[0], + null + ); + const pxDiff1 = calcMaxPxDiff(dataExtent, pxExtent, precision1); + const pxDiff2 = calcMaxPxDiff(dataExtent, pxExtent, precision2); + expect(pxDiff1).toBeFinite(); // May > 1 (bad case). + expect(pxDiff2).toBeLessThanOrEqual(1); + + // if (precision1 > 1) { + // console.log( + // dataExtent, pxExtent, '---', + // precision1, precision2, pxDiff1, pxDiff2 + // ); + // } + } + + for (let idx = 0; idx < NAN_CASES.length; idx++) { + const caseItem = NAN_CASES[idx]; + const dataExtent = caseItem[0]; + const pxExtent = caseItem[1]; + const precision2 = getAcceptablePrecision( + dataExtent[1] - dataExtent[0], + pxExtent[1] - pxExtent[0], + null + ); + const pxDiff2 = calcMaxPxDiff(dataExtent, pxExtent, precision2); + expect(pxDiff2).toBeNaN(); + } + + }); + }); \ No newline at end of file From d168bf237a442c2254fd494d6ae76e4e4712deff Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 9 Jan 2026 16:44:16 +0800 Subject: [PATCH 05/31] fix(axisTick&dataZoom): (1) Apply a better auto-precision method. (2) Make the rounding result consistent between dataZoom calculated window and specified axis `determinedMin/Max`. (3) Fix unexpected behaviors when dataZoom controls axes with `alignTicks: true` - previous they are not precisely aligned and the ticks jump significantly due to inappropriate rounding when dataZoom dragging. --- src/component/dataZoom/AxisProxy.ts | 202 +++++++++++++------- src/component/dataZoom/DataZoomModel.ts | 26 ++- src/component/dataZoom/SliderZoomView.ts | 58 +++--- src/component/dataZoom/dataZoomProcessor.ts | 31 ++- src/component/dataZoom/helper.ts | 26 ++- src/component/helper/sliderMove.ts | 19 +- src/component/toolbox/feature/DataZoom.ts | 39 +++- src/coord/Axis.ts | 15 +- 8 files changed, 270 insertions(+), 146 deletions(-) diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 11d5f6c742..cabe1db8cb 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -17,13 +17,15 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; -import * as numberUtil from '../../util/number'; +import {clone, defaults, each, map} from 'zrender/src/core/util'; +import { + asc, getAcceptableTickPrecision, linearMap, mathAbs, mathCeil, mathFloor, mathMax, mathMin, round +} from '../../util/number'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; import SeriesModel from '../../model/Series'; import ExtensionAPI from '../../core/ExtensionAPI'; -import { Dictionary } from '../../util/types'; +import { Dictionary, NullUndefined } from '../../util/types'; // TODO Polar? import DataZoomModel from './DataZoomModel'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; @@ -31,9 +33,8 @@ import { unionAxisExtentFromData } from '../../coord/axisHelper'; import { ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo'; import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './helper'; import { SINGLE_REFERRING } from '../../util/model'; +import { isOrdinalScale, isTimeScale } from '../../scale/helper'; -const each = zrUtil.each; -const asc = numberUtil.asc; interface MinMaxSpan { minSpan: number @@ -42,6 +43,17 @@ interface MinMaxSpan { maxValueSpan: number } +interface AxisProxyWindow { + value: [number, number]; + percent: [number, number]; + // Percent invert from "value window", which may be slightly different from "percent window" due to some + // handling such as rounding. The difference may be magnified in cases like "alignTicks", so we use + // `percentInverted` in these cases. + // But we retain the original input percent in `percent` whenever possible, since they have been used in views. + percentInverted: [number, number]; + valuePrecision: number; +} + /** * Operate single axis. * One axis can only operated by one axis operator. @@ -56,13 +68,16 @@ class AxisProxy { private _dimName: DataZoomAxisDimension; private _axisIndex: number; - private _valueWindow: [number, number]; - private _percentWindow: [number, number]; + private _window: AxisProxyWindow; private _dataExtent: [number, number]; private _minMaxSpan: MinMaxSpan; + /** + * The host `dataZoom` model. An axis may be controlled by multiple `dataZoom`s, + * but only the first declared `dataZoom` is the host. + */ private _dataZoomModel: DataZoomModel; constructor( @@ -94,17 +109,10 @@ class AxisProxy { } /** - * @return Value can only be NaN or finite value. + * @return `getWindow().value` can only have NaN or finite value. */ - getDataValueWindow() { - return this._valueWindow.slice() as [number, number]; - } - - /** - * @return {Array.} - */ - getDataPercentWindow() { - return this._percentWindow.slice() as [number, number]; + getWindow(): AxisProxyWindow { + return clone(this._window); } getTargetSeriesModels() { @@ -128,26 +136,31 @@ class AxisProxy { } getMinMaxSpan() { - return zrUtil.clone(this._minMaxSpan); + return clone(this._minMaxSpan); } /** + * [CAVEAT] Keep this method pure, so that it can be called multiple times. + * * Only calculate by given range and this._dataExtent, do not change anything. */ - calculateDataWindow(opt?: { - start?: number - end?: number - startValue?: number | string | Date - endValue?: number | string | Date - }) { + calculateDataWindow( + opt: { + start?: number // percent, 0 ~ 100 + end?: number // percent, 0 ~ 100 + startValue?: number | string | Date + endValue?: number | string | Date + } + ): AxisProxyWindow { const dataExtent = this._dataExtent; - const axisModel = this.getAxisModel(); - const scale = axisModel.axis.scale; + const axis = this.getAxisModel().axis; + const scale = axis.scale; const rangePropMode = this._dataZoomModel.getRangePropMode(); const percentExtent = [0, 100]; const percentWindow = [] as unknown as [number, number]; const valueWindow = [] as unknown as [number, number]; let hasPropModeValue; + const needRound = [false, false]; each(['start', 'end'] as const, function (prop, idx) { let boundPercent = opt[prop]; @@ -169,24 +182,19 @@ class AxisProxy { if (rangePropMode[idx] === 'percent') { boundPercent == null && (boundPercent = percentExtent[idx]); - // Use scale.parse to math round for category or time axis. - boundValue = scale.parse(numberUtil.linearMap( - boundPercent, percentExtent, dataExtent - )); + boundValue = linearMap(boundPercent, percentExtent, dataExtent); + needRound[idx] = true; } else { hasPropModeValue = true; + // NOTE: `scale.parse` can also round input for 'time' or 'ordinal' scale. boundValue = boundValue == null ? dataExtent[idx] : scale.parse(boundValue); // Calculating `percent` from `value` may be not accurate, because - // This calculation can not be inversed, because all of values that + // This calculation can not be inverted, because all of values that // are overflow the `dataExtent` will be calculated to percent '100%' - boundPercent = numberUtil.linearMap( - boundValue, dataExtent, percentExtent - ); + boundPercent = linearMap(boundValue, dataExtent, percentExtent); } - // valueWindow[idx] = round(boundValue); - // percentWindow[idx] = round(boundPercent); // fallback to extent start/end when parsed value or percent is invalid valueWindow[idx] = boundValue == null || isNaN(boundValue) ? dataExtent[idx] @@ -199,11 +207,17 @@ class AxisProxy { asc(valueWindow); asc(percentWindow); - // The windows from user calling of `dispatchAction` might be out of the extent, - // or do not obey the `min/maxSpan`, `min/maxValueSpan`. But we don't restrict window - // by `zoomLock` here, because we see `zoomLock` just as a interaction constraint, - // where API is able to initialize/modify the window size even though `zoomLock` - // specified. + // The windows specified from `dispatchAction` or `setOption` may: + // (1) be out of the extent, or + // (2) do not comply with `minSpan/maxSpan`, `minValueSpan/maxValueSpan`. + // So we clamp them here. + // But we don't restrict window by `zoomLock` here, because we see `zoomLock` just as a + // interaction constraint, where API is able to initialize/modify the window size even + // though `zoomLock` specified. + // PENDING: For historical reason, the option design is partially incompatible: + // If `option.start` and `option.endValue` are specified, and when we choose whether + // `min/maxValueSpan` or `minSpan/maxSpan` is applied, neither one is intuitive. + // (Currently using `minValueSpan/maxValueSpan`.) const spans = this._minMaxSpan; hasPropModeValue ? restrictSet(valueWindow, percentWindow, dataExtent, percentExtent, false) @@ -223,14 +237,67 @@ class AxisProxy { spans['max' + suffix as 'maxSpan' | 'maxValueSpan'] ); for (let i = 0; i < 2; i++) { - toWindow[i] = numberUtil.linearMap(fromWindow[i], fromExtent, toExtent, true); - toValue && (toWindow[i] = scale.parse(toWindow[i])); + toWindow[i] = linearMap(fromWindow[i], fromExtent, toExtent, true); + if (toValue) { + toWindow[i] = toWindow[i]; + needRound[i] = true; + } + } + simplyEnsureAsc(toWindow); + } + + // - In 'time' and 'ordinal' scale, rounding by 0 is required. + // - In 'interval' and 'log' scale, we round values for acceptable display with acceptable accuracy loose. + // "Values" can be rounded only if they are generated from `percent`, since user-specified "value" + // should be respected, and `DataZoomSelect` already performs its own rounding. + // - Currently we only round "value" but not "percent", since there is no need so far. + // - MEMO: See also #3228 and commit a89fd0d7f1833ecf08a4a5b7ecf651b4a0d8da41 + // - PENDING: The rounding result may slightly overflow the restriction from `min/maxSpan`, + // but it is acceptable so far. + const isScaleOrdinalOrTime = isOrdinalScale(scale) || isTimeScale(scale); + // Typically pxExtent has been ready in coordSys create. (See `create` of `Grid.ts`) + const pxExtent = axis.getExtent(); + // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable. + const pxSpan = mathAbs(pxExtent[1] - pxExtent[0]); + const precision = isScaleOrdinalOrTime + ? 0 + : getAcceptableTickPrecision(valueWindow[1] - valueWindow[0], pxSpan, 0.5); + each([[0, mathCeil], [1, mathFloor]] as const, function ([idx, ceilOrFloor]) { + if (!needRound[idx] || !isFinite(precision)) { + return; + } + valueWindow[idx] = round(valueWindow[idx], precision); + valueWindow[idx] = mathMin(dataExtent[1], mathMax(dataExtent[0], valueWindow[idx])); // Clamp. + if (percentWindow[idx] === percentExtent[idx]) { + // When `percent` is 0 or 100, `value` must be `dataExtent[0]` or `dataExtent[1]` + // regardless of the calculated precision. + // NOTE: `percentWindow` is never over [0, 100] at this moment. + valueWindow[idx] = dataExtent[idx]; + if (isScaleOrdinalOrTime) { + // In case that dataExtent[idx] is not an integer (may occur since it comes from user input) + valueWindow[idx] = ceilOrFloor(valueWindow[idx]); + } + } + }); + simplyEnsureAsc(valueWindow); + + const percentInvertedWindow = [ + linearMap(valueWindow[0], dataExtent, percentExtent, true), + linearMap(valueWindow[1], dataExtent, percentExtent, true), + ] as [number, number]; + simplyEnsureAsc(percentInvertedWindow); + + function simplyEnsureAsc(window: number[]): void { + if (window[0] > window[1]) { + window[0] = window[1]; } } return { - valueWindow: valueWindow, - percentWindow: percentWindow + value: valueWindow, + percent: percentWindow, + percentInverted: percentInvertedWindow, + valuePrecision: precision, }; } @@ -239,8 +306,8 @@ class AxisProxy { * so it is recommended to be called in "process stage" but not "model init * stage". */ - reset(dataZoomModel: DataZoomModel) { - if (dataZoomModel !== this._dataZoomModel) { + reset(dataZoomModel: DataZoomModel, alignToPercentInverted: [number, number] | NullUndefined) { + if (!this.hostedBy(dataZoomModel)) { return; } @@ -251,24 +318,28 @@ class AxisProxy { // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); - const dataWindow = this.calculateDataWindow(dataZoomModel.settledOption); - - this._valueWindow = dataWindow.valueWindow; - this._percentWindow = dataWindow.percentWindow; + let opt = dataZoomModel.settledOption; + if (alignToPercentInverted) { + opt = defaults({ + start: alignToPercentInverted[0], + end: alignToPercentInverted[1], + }, opt); + } + this._window = this.calculateDataWindow(opt); // Update axis setting then. this._setAxisModel(); } filterData(dataZoomModel: DataZoomModel, api: ExtensionAPI) { - if (dataZoomModel !== this._dataZoomModel) { + if (!this.hostedBy(dataZoomModel)) { return; } const axisDim = this._dimName; const seriesModels = this.getTargetSeriesModels(); const filterMode = dataZoomModel.get('filterMode'); - const valueWindow = this._valueWindow; + const valueWindow = this._window.value; if (filterMode === 'none') { return; @@ -305,7 +376,7 @@ class AxisProxy { if (filterMode === 'weakFilter') { const store = seriesData.getStore(); - const dataDimIndices = zrUtil.map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); + const dataDimIndices = map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); seriesData.filterSelf(function (dataIndex) { let leftOut; let rightOut; @@ -368,12 +439,12 @@ class AxisProxy { // minValueSpan and maxValueSpan has higher priority than minSpan and maxSpan if (valueSpan != null) { - percentSpan = numberUtil.linearMap( + percentSpan = linearMap( dataExtent[0] + valueSpan, dataExtent, [0, 100], true ); } else if (percentSpan != null) { - valueSpan = numberUtil.linearMap( + valueSpan = linearMap( percentSpan, [0, 100], dataExtent, true ) - dataExtent[0]; } @@ -387,27 +458,22 @@ class AxisProxy { const axisModel = this.getAxisModel(); - const percentWindow = this._percentWindow; - const valueWindow = this._valueWindow; - - if (!percentWindow) { + const window = this._window; + if (!window) { return; } - - // [0, 500]: arbitrary value, guess axis extent. - let precision = numberUtil.getPixelPrecision(valueWindow, [0, 500]); - precision = Math.min(precision, 20); + const {percent, value} = window; // For value axis, if min/max/scale are not set, we just use the extent obtained // by series data, which may be a little different from the extent calculated by // `axisHelper.getScaleExtent`. But the different just affects the experience a // little when zooming. So it will not be fixed until some users require it strongly. const rawExtentInfo = axisModel.axis.scale.rawExtentInfo; - if (percentWindow[0] !== 0) { - rawExtentInfo.setDeterminedMinMax('min', +valueWindow[0].toFixed(precision)); + if (percent[0] !== 0) { + rawExtentInfo.setDeterminedMinMax('min', value[0]); } - if (percentWindow[1] !== 100) { - rawExtentInfo.setDeterminedMinMax('max', +valueWindow[1].toFixed(precision)); + if (percent[1] !== 100) { + rawExtentInfo.setDeterminedMinMax('max', value[1]); } rawExtentInfo.freeze(); } diff --git a/src/component/dataZoom/DataZoomModel.ts b/src/component/dataZoom/DataZoomModel.ts index 279c06a13b..c83e166cbc 100644 --- a/src/component/dataZoom/DataZoomModel.ts +++ b/src/component/dataZoom/DataZoomModel.ts @@ -23,13 +23,15 @@ import ComponentModel from '../../model/Component'; import { LayoutOrient, ComponentOption, - LabelOption + LabelOption, + NullUndefined } from '../../util/types'; import Model from '../../model/Model'; import GlobalModel from '../../model/Global'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { - getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension + getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension, + getAxisProxyFromModel } from './helper'; import SingleAxisModel from '../../coord/single/AxisModel'; import { MULTIPLE_REFERRING, SINGLE_REFERRING, ModelFinderIndexQuery, ModelFinderIdQuery } from '../../util/model'; @@ -131,15 +133,11 @@ export interface DataZoomOption extends ComponentOption { type RangeOption = Pick; -export type DataZoomExtendedAxisBaseModel = AxisBaseModel & { - __dzAxisProxy: AxisProxy -}; - class DataZoomAxisInfo { indexList: number[] = []; indexMap: boolean[] = []; - add(axisCmptIdx: number) { + add(axisCmptIdx: ComponentModel['componentIndex']): void { // Remove duplication. if (!this.indexMap[axisCmptIdx]) { this.indexList.push(axisCmptIdx); @@ -456,10 +454,7 @@ class DataZoomModel extends Compon * @return If not found, return null/undefined. */ getAxisProxy(axisDim: DataZoomAxisDimension, axisIndex: number): AxisProxy { - const axisModel = this.getAxisModel(axisDim, axisIndex); - if (axisModel) { - return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; - } + return getAxisProxyFromModel(this.getAxisModel(axisDim, axisIndex)); } /** @@ -510,7 +505,7 @@ class DataZoomModel extends Compon getPercentRange(): number[] { const axisProxy = this.findRepresentativeAxisProxy(); if (axisProxy) { - return axisProxy.getDataPercentWindow(); + return axisProxy.getWindow().percent; } } @@ -523,11 +518,11 @@ class DataZoomModel extends Compon if (axisDim == null && axisIndex == null) { const axisProxy = this.findRepresentativeAxisProxy(); if (axisProxy) { - return axisProxy.getDataValueWindow(); + return axisProxy.getWindow().value; } } else { - return this.getAxisProxy(axisDim, axisIndex).getDataValueWindow(); + return this.getAxisProxy(axisDim, axisIndex).getWindow().value; } } @@ -537,7 +532,7 @@ class DataZoomModel extends Compon */ findRepresentativeAxisProxy(axisModel?: AxisBaseModel): AxisProxy { if (axisModel) { - return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; + return getAxisProxyFromModel(axisModel); } // Find the first hosted axisProxy @@ -576,6 +571,7 @@ class DataZoomModel extends Compon } } + /** * Retrieve those raw params from option, which will be cached separately, * because they will be overwritten by normalized/calculated values in the main diff --git a/src/component/dataZoom/SliderZoomView.ts b/src/component/dataZoom/SliderZoomView.ts index 30422c9c52..ad82f0fca3 100644 --- a/src/component/dataZoom/SliderZoomView.ts +++ b/src/component/dataZoom/SliderZoomView.ts @@ -22,7 +22,7 @@ import * as eventTool from 'zrender/src/core/event'; import * as graphic from '../../util/graphic'; import * as throttle from '../../util/throttle'; import DataZoomView from './DataZoomView'; -import { linearMap, asc, parsePercent } from '../../util/number'; +import { linearMap, asc, parsePercent, round } from '../../util/number'; import * as layout from '../../util/layout'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; @@ -35,7 +35,7 @@ import { RectLike } from 'zrender/src/core/BoundingRect'; import Axis from '../../coord/Axis'; import SeriesModel from '../../model/Series'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; -import { getAxisMainType, collectReferCoordSysModelInfo } from './helper'; +import { getAxisMainType, collectReferCoordSysModelInfo, getAlignTo } from './helper'; import { enableHoverEmphasis } from '../../util/states'; import { createSymbol, symbolBuildProxies } from '../../util/symbol'; import { deprecateLog } from '../../util/log'; @@ -44,6 +44,8 @@ import Displayable from 'zrender/src/graphic/Displayable'; import { createTextStyle } from '../../label/labelStyle'; import SeriesData from '../../data/SeriesData'; import tokens from '../../visual/tokens'; +import type AxisProxy from './AxisProxy'; +import { isOrdinalScale, isTimeScale } from '../../scale/helper'; const Rect = graphic.Rect; @@ -816,30 +818,35 @@ class SliderZoomView extends DataZoomView { private _updateDataInfo(nonRealtime?: boolean) { const dataZoomModel = this.dataZoomModel; - const displaybles = this._displayables; - const handleLabels = displaybles.handleLabels; + const displayables = this._displayables; + const handleLabels = displayables.handleLabels; const orient = this._orient; let labelTexts = ['', '']; - // FIXME - // date型,支持formatter,autoformatter(ec2 date.getAutoFormatter) if (dataZoomModel.get('showDetail')) { const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); if (axisProxy) { - const axis = axisProxy.getAxisModel().axis; const range = this._range; - const dataInterval = nonRealtime + let dataInterval: [number, number]; + if (nonRealtime) { // See #4434, data and axis are not processed and reset yet in non-realtime mode. - ? axisProxy.calculateDataWindow({ - start: range[0], end: range[1] - }).valueWindow - : axisProxy.getDataValueWindow(); + let calcWinInput = {start: range[0], end: range[1]}; + const alignTo = getAlignTo(dataZoomModel, axisProxy); + if (alignTo) { + const alignToWindow = alignTo.calculateDataWindow(calcWinInput).percentInverted; + calcWinInput = {start: alignToWindow[0], end: alignToWindow[1]}; + } + dataInterval = axisProxy.calculateDataWindow(calcWinInput).value; + } + else { + dataInterval = axisProxy.getWindow().value; + } labelTexts = [ - this._formatLabel(dataInterval[0], axis), - this._formatLabel(dataInterval[1], axis) + this._formatLabel(dataInterval[0], axisProxy), + this._formatLabel(dataInterval[1], axisProxy) ]; } } @@ -854,7 +861,7 @@ class SliderZoomView extends DataZoomView { // Text should not transform by barGroup. // Ignore handlers transform const barTransform = graphic.getTransform( - displaybles.handles[handleIndex].parent, this.group + displayables.handles[handleIndex].parent, this.group ); const direction = graphic.transformDirection( handleIndex === 0 ? 'right' : 'left', barTransform @@ -877,27 +884,26 @@ class SliderZoomView extends DataZoomView { } } - private _formatLabel(value: ParsedValue, axis: Axis) { + private _formatLabel(value: number, axisProxy: AxisProxy) { const dataZoomModel = this.dataZoomModel; const labelFormatter = dataZoomModel.get('labelFormatter'); let labelPrecision = dataZoomModel.get('labelPrecision'); if (labelPrecision == null || labelPrecision === 'auto') { - labelPrecision = axis.getPixelPrecision(); + labelPrecision = axisProxy.getWindow().valuePrecision; } - const valueStr = (value == null || isNaN(value as number)) + const scale = axisProxy.getAxisModel().axis.scale; + const valueStr = (value == null || isNaN(value)) ? '' - // FIXME Glue code - : (axis.type === 'category' || axis.type === 'time') - ? axis.scale.getLabel({ - value: Math.round(value as number) - }) - // param of toFixed should less then 20. - : (value as number).toFixed(Math.min(labelPrecision as number, 20)); + : (isOrdinalScale(scale) || isTimeScale(scale)) + ? scale.getLabel({value: Math.round(value)}) + : isFinite(labelPrecision) + ? round(value, labelPrecision, true) + : value + ''; return isFunction(labelFormatter) - ? labelFormatter(value as number, valueStr) + ? labelFormatter(value, valueStr) : isString(labelFormatter) ? labelFormatter.replace('{value}', valueStr) : valueStr; diff --git a/src/component/dataZoom/dataZoomProcessor.ts b/src/component/dataZoom/dataZoomProcessor.ts index f511aaed31..d64269295a 100644 --- a/src/component/dataZoom/dataZoomProcessor.ts +++ b/src/component/dataZoom/dataZoomProcessor.ts @@ -19,11 +19,12 @@ import {createHashMap, each} from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; -import DataZoomModel, { DataZoomExtendedAxisBaseModel } from './DataZoomModel'; -import { getAxisMainType, DataZoomAxisDimension } from './helper'; +import DataZoomModel from './DataZoomModel'; +import { getAxisMainType, DataZoomAxisDimension, DataZoomExtendedAxisBaseModel, getAlignTo } from './helper'; import AxisProxy from './AxisProxy'; import { StageHandler } from '../../util/types'; + const dataZoomProcessor: StageHandler = { // `dataZoomProcessor` will only be performed in needed series. Consider if @@ -54,7 +55,7 @@ const dataZoomProcessor: StageHandler = { }); const proxyList: AxisProxy[] = []; eachAxisModel(function (axisDim, axisIndex, axisModel, dataZoomModel) { - // Different dataZooms may constrol the same axis. In that case, + // Different dataZooms may control the same axis. In that case, // an axisProxy serves both of them. if (!axisModel.__dzAxisProxy) { // Use the first dataZoomModel as the main model of axisProxy. @@ -82,8 +83,19 @@ const dataZoomProcessor: StageHandler = { // We calculate window and reset axis here but not in model // init stage and not after action dispatch handler, because // reset should be called after seriesData.restoreData. + const axisProxyNeedAlign: [AxisProxy, AxisProxy][] = []; dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) { - dataZoomModel.getAxisProxy(axisDim, axisIndex).reset(dataZoomModel); + const axisProxy = dataZoomModel.getAxisProxy(axisDim, axisIndex); + const alignToAxisProxy = getAlignTo(dataZoomModel, axisProxy); + if (alignToAxisProxy) { + axisProxyNeedAlign.push([axisProxy, alignToAxisProxy]); + } + else { + axisProxy.reset(dataZoomModel, null); + } + }); + each(axisProxyNeedAlign, function (item) { + item[0].reset(dataZoomModel, item[1].getWindow().percentInverted); }); // Caution: data zoom filtering is order sensitive when using @@ -110,14 +122,13 @@ const dataZoomProcessor: StageHandler = { // is able to get them from chart.getOption(). const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); if (axisProxy) { - const percentRange = axisProxy.getDataPercentWindow(); - const valueRange = axisProxy.getDataValueWindow(); + const {percent, value} = axisProxy.getWindow(); dataZoomModel.setCalculatedRange({ - start: percentRange[0], - end: percentRange[1], - startValue: valueRange[0], - endValue: valueRange[1] + start: percent[0], + end: percent[1], + startValue: value[0], + endValue: value[1] }); } }); diff --git a/src/component/dataZoom/helper.ts b/src/component/dataZoom/helper.ts index 4b0a8a31e4..e2009cb764 100644 --- a/src/component/dataZoom/helper.ts +++ b/src/component/dataZoom/helper.ts @@ -17,13 +17,14 @@ * under the License. */ -import { Payload } from '../../util/types'; +import { NullUndefined, Payload } from '../../util/types'; import GlobalModel from '../../model/Global'; import DataZoomModel from './DataZoomModel'; import { indexOf, createHashMap, assert, HashMap } from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; +import type AxisProxy from './AxisProxy'; export interface DataZoomPayloadBatchItem { @@ -43,6 +44,10 @@ export interface DataZoomReferCoordSysInfo { axisModels: AxisBaseModel[]; } +export type DataZoomExtendedAxisBaseModel = AxisBaseModel & { + __dzAxisProxy: AxisProxy +}; + export const DATA_ZOOM_AXIS_DIMENSIONS = [ 'x', 'y', 'radius', 'angle', 'single' ] as const; @@ -205,3 +210,22 @@ export function collectReferCoordSysModelInfo(dataZoomModel: DataZoomModel): { return coordSysInfoWrap; } + +export function getAxisProxyFromModel(axisModel: AxisBaseModel): AxisProxy | NullUndefined { + return axisModel && (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; +} + +/** + * NOTICE: If `axis_a` aligns to `axis_b`, but they are not controlled by + * the same `dataZoom`, do not consider `axis_b` as `alignTo` and + * then do not input it into `AxisProxy#reset`. + */ +export function getAlignTo(dataZoomModel: DataZoomModel, axisProxy: AxisProxy): AxisProxy | NullUndefined { + const alignToAxis = axisProxy.getAxisModel().axis.alignTo; + return ( + alignToAxis && dataZoomModel.getAxisProxy( + alignToAxis.dim as DataZoomAxisDimension, + alignToAxis.model.componentIndex + ) + ) ? getAxisProxyFromModel(alignToAxis.model) : null; +} diff --git a/src/component/helper/sliderMove.ts b/src/component/helper/sliderMove.ts index 902312d301..721f6704a2 100644 --- a/src/component/helper/sliderMove.ts +++ b/src/component/helper/sliderMove.ts @@ -17,6 +17,8 @@ * under the License. */ +import { addSafe } from '../../util/number'; + /** * Calculate slider move result. * Usage: @@ -24,6 +26,9 @@ * maxSpan and the same as `Math.abs(handleEnd[1] - handleEnds[0])`. * (2) If handle0 is forbidden to cross handle1, set minSpan as `0`. * + * [CAVEAT] + * This method is inefficient due to the use of `addSafe`. + * * @param delta Move length. * @param handleEnds handleEnds[0] can be bigger then handleEnds[1]. * handleEnds will be modified in this method. @@ -48,7 +53,9 @@ export default function sliderMove( delta = delta || 0; - const extentSpan = extent[1] - extent[0]; + // Consider `7.1e-9 - 7e-9` get `1.0000000000000007e-10`, so use `addSafe` + // to remove rounding error whenever possible. + const extentSpan = addSafe(extent[1], -extent[0]); // Notice maxSpan and minSpan can be null/undefined. if (minSpan != null) { @@ -58,7 +65,7 @@ export default function sliderMove( maxSpan = Math.max(maxSpan, minSpan != null ? minSpan : 0); } if (handleIndex === 'all') { - let handleSpan = Math.abs(handleEnds[1] - handleEnds[0]); + let handleSpan = Math.abs(addSafe(handleEnds[1], -handleEnds[0])); handleSpan = restrict(handleSpan, [0, extentSpan]); minSpan = maxSpan = restrict(handleSpan, [minSpan, maxSpan]); handleIndex = 0; @@ -74,7 +81,9 @@ export default function sliderMove( // Restrict in extent. const extentMinSpan = minSpan || 0; const realExtent = extent.slice(); - originalDistSign.sign < 0 ? (realExtent[0] += extentMinSpan) : (realExtent[1] -= extentMinSpan); + originalDistSign.sign < 0 + ? (realExtent[0] = addSafe(realExtent[0], extentMinSpan)) + : (realExtent[1] = addSafe(realExtent[1], -extentMinSpan)); handleEnds[handleIndex] = restrict(handleEnds[handleIndex], realExtent); // Expand span. @@ -84,13 +93,13 @@ export default function sliderMove( currDistSign.sign !== originalDistSign.sign || currDistSign.span < minSpan )) { // If minSpan exists, 'cross' is forbidden. - handleEnds[1 - handleIndex] = handleEnds[handleIndex] + originalDistSign.sign * minSpan; + handleEnds[1 - handleIndex] = addSafe(handleEnds[handleIndex], originalDistSign.sign * minSpan); } // Shrink span. currDistSign = getSpanSign(handleEnds, handleIndex); if (maxSpan != null && currDistSign.span > maxSpan) { - handleEnds[1 - handleIndex] = handleEnds[handleIndex] + currDistSign.sign * maxSpan; + handleEnds[1 - handleIndex] = addSafe(handleEnds[handleIndex], currDistSign.sign * maxSpan); } return handleEnds; diff --git a/src/component/toolbox/feature/DataZoom.ts b/src/component/toolbox/feature/DataZoom.ts index e51ace23cf..85a1727e84 100644 --- a/src/component/toolbox/feature/DataZoom.ts +++ b/src/component/toolbox/feature/DataZoom.ts @@ -35,9 +35,7 @@ import { Payload, Dictionary, ComponentOption, ItemStyleOption } from '../../../ import Cartesian2D from '../../../coord/cartesian/Cartesian2D'; import CartesianAxisModel from '../../../coord/cartesian/AxisModel'; import DataZoomModel from '../../dataZoom/DataZoomModel'; -import { - DataZoomPayloadBatchItem, DataZoomAxisDimension -} from '../../dataZoom/helper'; +import {DataZoomPayloadBatchItem} from '../../dataZoom/helper'; import { ModelFinderObject, ModelFinderIndexQuery, makeInternalComponentId, ModelFinderIdQuery, parseFinder, ParsedModelFinderKnown @@ -46,6 +44,8 @@ import ToolboxModel from '../ToolboxModel'; import { registerInternalOptionCreator } from '../../../model/internalComponentCreator'; import ComponentModel from '../../../model/Component'; import tokens from '../../../visual/tokens'; +import BoundingRect from 'zrender/src/core/BoundingRect'; +import { getAcceptableTickPrecision, round } from '../../../util/number'; const each = zrUtil.each; @@ -55,6 +55,9 @@ const DATA_ZOOM_ID_BASE = makeInternalComponentId('toolbox-dataZoom_'); const ICON_TYPES = ['zoom', 'back'] as const; type IconType = typeof ICON_TYPES[number]; +const XY2WH = {x: 'width', y: 'height'} as const; + + export interface ToolboxDataZoomFeatureOption extends ToolboxFeatureOption { type?: IconType[] icon?: {[key in IconType]?: string} @@ -135,15 +138,18 @@ class DataZoomFeature extends ToolboxFeature { return; } + const coordSysRect = coordSys.master.getRect().clone(); + const brushType = area.brushType; if (brushType === 'rect') { - setBatch('x', coordSys, (coordRange as BrushDimensionMinMax[])[0]); - setBatch('y', coordSys, (coordRange as BrushDimensionMinMax[])[1]); + setBatch('x', coordSys, coordSysRect, (coordRange as BrushDimensionMinMax[])[0]); + setBatch('y', coordSys, coordSysRect, (coordRange as BrushDimensionMinMax[])[1]); } else { setBatch( ({lineX: 'x', lineY: 'y'} as const)[brushType as 'lineX' | 'lineY'], coordSys, + coordSysRect, coordRange as BrushDimensionMinMax ); } @@ -153,29 +159,42 @@ class DataZoomFeature extends ToolboxFeature { this._dispatchZoomAction(snapshot); - function setBatch(dimName: DataZoomAxisDimension, coordSys: Cartesian2D, minMax: number[]) { + function setBatch( + dimName: 'x' | 'y', + coordSys: Cartesian2D, + coordSysRect: BoundingRect, + minMax: number[] + ) { const axis = coordSys.getAxis(dimName); const axisModel = axis.model; const dataZoomModel = findDataZoom(dimName, axisModel, ecModel); // Restrict range. const minMaxSpan = dataZoomModel.findRepresentativeAxisProxy(axisModel).getMinMaxSpan(); + const scaleExtent = axis.scale.getExtent(); if (minMaxSpan.minValueSpan != null || minMaxSpan.maxValueSpan != null) { minMax = sliderMove( - 0, minMax.slice(), axis.scale.getExtent(), 0, + 0, minMax.slice(), scaleExtent, 0, minMaxSpan.minValueSpan, minMaxSpan.maxValueSpan ); } + // Round for displayable. + const precision = getAcceptableTickPrecision( + scaleExtent[1] - scaleExtent[0], + coordSysRect[XY2WH[dimName]], + 0.5 + ); + dataZoomModel && (snapshot[dataZoomModel.id] = { dataZoomId: dataZoomModel.id, - startValue: minMax[0], - endValue: minMax[1] + startValue: isFinite(precision) ? round(minMax[0], precision) : minMax[0], + endValue: isFinite(precision) ? round(minMax[1], precision) : minMax[1] }); } function findDataZoom( - dimName: DataZoomAxisDimension, axisModel: CartesianAxisModel, ecModel: GlobalModel + dimName: 'x' | 'y', axisModel: CartesianAxisModel, ecModel: GlobalModel ): DataZoomModel { let found; ecModel.eachComponent({mainType: 'dataZoom', subType: 'select'}, function (dzModel: DataZoomModel) { diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index 6226e96b26..0390875e28 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -18,7 +18,7 @@ */ import {each, map} from 'zrender/src/core/util'; -import {linearMap, getPixelPrecision, round} from '../util/number'; +import {linearMap, round} from '../util/number'; import { createAxisTicks, createAxisLabels, @@ -80,6 +80,9 @@ class Axis { // `inverse` can be inferred by `extent` unless `extent[0] === extent[1]`. inverse: AxisBaseOption['inverse'] = false; + // Injected outside + alignTo: Axis; + constructor(dim: DimensionName, scale: Scale, extent: [number, number]) { this.dim = dim; @@ -111,16 +114,6 @@ class Axis { return this._extent.slice() as [number, number]; } - /** - * Get precision used for formatting - */ - getPixelPrecision(dataExtent?: [number, number]): number { - return getPixelPrecision( - dataExtent || this.scale.getExtent(), - this._extent - ); - } - /** * Set coord extent */ From ffcc636fbbe3d46107f70f41dbe2df1d33fe1e88 Mon Sep 17 00:00:00 2001 From: 100pah Date: Sat, 10 Jan 2026 00:53:28 +0800 Subject: [PATCH 06/31] fix(alignTicks): Change alignTick strategy: (1) Previously some series data may be out of the calculated extent and can not be displayed. (2) Previously the precision is incorrect for small float number (fixed at 10 rather than based on the magnitude of the value). (3) Make the tick precision more acceptable when min/max of axis is fixed, and remove console warning, because whey can be specified when dataZoom dragging. (4) Clarify the related code for LogScale. --- src/coord/axisAlignTicks.ts | 334 +++++--- src/coord/axisCommonTypes.ts | 4 +- src/coord/axisHelper.ts | 125 ++- src/coord/axisModelCommonMixin.ts | 3 +- src/coord/cartesian/Grid.ts | 108 ++- .../cartesian/defaultAxisExtentFromData.ts | 2 +- src/coord/parallel/Parallel.ts | 21 +- src/coord/polar/polarCreator.ts | 14 +- src/coord/radar/Radar.ts | 3 +- src/coord/scaleRawExtentInfo.ts | 60 +- src/coord/single/Single.ts | 8 +- src/export/api/helper.ts | 11 +- src/scale/Interval.ts | 174 ++-- src/scale/Log.ts | 172 ++-- src/scale/Scale.ts | 2 +- src/scale/helper.ts | 9 +- src/util/number.ts | 11 +- test/axis-align-edge-cases.html | 746 ++++++++++++++++++ test/axis-align-ticks-random.html | 4 + test/runTest/actions/__meta__.json | 1 + .../actions/axis-align-edge-cases.json | 1 + test/runTest/marks/axis-align-edge-cases.json | 10 + test/runTest/marks/axis-align-lastLabel.json | 8 + .../marks/axis-align-ticks-random.json | 10 + 24 files changed, 1459 insertions(+), 382 deletions(-) create mode 100644 test/axis-align-edge-cases.html create mode 100644 test/runTest/actions/axis-align-edge-cases.json create mode 100644 test/runTest/marks/axis-align-edge-cases.json create mode 100644 test/runTest/marks/axis-align-ticks-random.json diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 3080cf69d2..4c4a6f8ac7 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -17,124 +17,270 @@ * under the License. */ -import { NumericAxisBaseOptionCommon } from './axisCommonTypes'; -import { getPrecisionSafe, round } from '../util/number'; +import { + getAcceptableTickPrecision, + mathAbs, mathCeil, mathFloor, mathMax, nice, NICE_MODE_MIN, round +} from '../util/number'; import IntervalScale from '../scale/Interval'; -import { getScaleExtent, retrieveAxisBreaksOption } from './axisHelper'; +import { adoptScaleExtentOptionAndPrepare } from './axisHelper'; import { AxisBaseModel } from './AxisBaseModel'; import LogScale from '../scale/Log'; import { warn } from '../util/log'; -import { logTransform, increaseInterval, isValueNice } from '../scale/helper'; +import { + increaseInterval, isLogScale, getIntervalPrecision, intervalScaleEnsureValidExtent, + logTransform, +} from '../scale/helper'; +import { assert } from 'zrender/src/core/util'; +import { NullUndefined } from '../util/types'; export function alignScaleTicks( - scale: IntervalScale | LogScale, - axisModel: AxisBaseModel>, + targetScale: IntervalScale | LogScale, + targetDataExtent: number[], + targetAxisModel: AxisBaseModel, alignToScale: IntervalScale | LogScale -) { - - const intervalScaleProto = IntervalScale.prototype; - - // NOTE: There is a precondition for log scale here: - // In log scale we store _interval and _extent of exponent value. - // So if we use the method of InternalScale to set/get these data. - // It process the exponent value, which is linear and what we want here. - const alignToTicks = intervalScaleProto.getTicks.call(alignToScale); - const alignToNicedTicks = intervalScaleProto.getTicks.call(alignToScale, {expandToNicedExtent: true}); - const alignToSplitNumber = alignToTicks.length - 1; - const alignToInterval = intervalScaleProto.getInterval.call(alignToScale); - - const scaleExtent = getScaleExtent(scale, axisModel); - let rawExtent = scaleExtent.extent; - const isMinFixed = scaleExtent.fixMin; - const isMaxFixed = scaleExtent.fixMax; - - if (scale.type === 'log') { - rawExtent = logTransform((scale as LogScale).base, rawExtent, true); +): void { + const isTargetLogScale = isLogScale(targetScale); + const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.linearStub : alignToScale; + + const alignToTicks = alignToScaleLinear.getTicks(); + const alignToExpNiceTicks = alignToScaleLinear.getTicks({expandToNicedExtent: true}); + const alignToSegCount = alignToTicks.length - 1; + + if (__DEV__) { + // This is guards for future changes of `Interval#getTicks`. + assert(!alignToScale.hasBreaks() && !targetScale.hasBreaks()); + assert(alignToSegCount > 0); // Ticks length >= 2 even on a blank scale. + assert(alignToExpNiceTicks.length === alignToTicks.length); + assert(alignToTicks[0].value <= alignToTicks[alignToSegCount].value); + assert( + alignToExpNiceTicks[0].value <= alignToTicks[0].value + && alignToTicks[alignToSegCount].value <= alignToExpNiceTicks[alignToSegCount].value + ); + if (alignToSegCount >= 2) { + assert(alignToExpNiceTicks[1].value === alignToTicks[1].value); + assert(alignToExpNiceTicks[alignToSegCount - 1].value === alignToTicks[alignToSegCount - 1].value); + } } - scale.setBreaksFromOption(retrieveAxisBreaksOption(axisModel)); - scale.setExtent(rawExtent[0], rawExtent[1]); - scale.calcNiceExtent({ - splitNumber: alignToSplitNumber, - fixMin: isMinFixed, - fixMax: isMaxFixed - }); - const extent = intervalScaleProto.getExtent.call(scale); - // Need to update the rawExtent. - // Because value in rawExtent may be not parsed. e.g. 'dataMin', 'dataMax' - if (isMinFixed) { - rawExtent[0] = extent[0]; + // The Current strategy: Find a proper interval and an extent for the target scale to derive ticks + // matching exactly to ticks of `alignTo` scale. + + // Adjust min, max based on the extent of alignTo. When min or max is set in alignTo scale + let t0: number; // diff ratio on min irregular segment. 0 <= t0 < 1 + let t1: number; // diff ratio on max irregular segment. 0 <= t1 < 1 + let alignToRegularSegCount: number; // >= 1 + // Consider ticks of `alignTo`, only these cases below may occur: + if (alignToSegCount === 1) { + // `alignToTicks` is like: + // |--| + // In this case, we make the corresponding 2 target ticks "nice". + t0 = t1 = 0; + alignToRegularSegCount = 1; } - if (isMaxFixed) { - rawExtent[1] = extent[1]; + else if (alignToSegCount === 2) { + // `alignToTicks` is like: + // |-|-----| or + // |-----|-| or + // |-----|-----| + // Notices that nice ticks do not necessarily exist in this case. + // In this case, we choose the larger segment as the "regular segment" and + // the corresponding target ticks are made "nice". + const interval0 = mathAbs(alignToTicks[0].value - alignToTicks[1].value); + const interval1 = mathAbs(alignToTicks[1].value - alignToTicks[2].value); + t0 = t1 = 0; + if (interval0 === interval1) { + alignToRegularSegCount = 2; + } + else { + alignToRegularSegCount = 1; + if (interval0 < interval1) { + t0 = interval0 / interval1; + } + else { + t1 = interval1 / interval0; + } + } + } + else { // alignToSegCount >= 3 + // `alignToTicks` is like: + // |-|-----|-----|-| or + // |-----|-----|-| or + // |-|-----|-----| or ... + // At least one regular segment is present, and irregular segments are only present on + // the start and/or the end. + // In this case, ticks corresponding to regular segments are made "nice". + const alignToInterval = alignToScaleLinear.getInterval(); + t0 = ( + 1 - (alignToTicks[0].value - alignToExpNiceTicks[0].value) / alignToInterval + ) % 1; + t1 = ( + 1 - (alignToExpNiceTicks[alignToSegCount].value - alignToTicks[alignToSegCount].value) / alignToInterval + ) % 1; + alignToRegularSegCount = alignToSegCount - (t0 ? 1 : 0) - (t1 ? 1 : 0); } - let interval = intervalScaleProto.getInterval.call(scale); - let min: number = rawExtent[0]; - let max: number = rawExtent[1]; + if (__DEV__) { + assert(alignToRegularSegCount >= 1); + } - if (isMinFixed && isMaxFixed) { - // User set min, max, divide to get new interval - interval = (max - min) / alignToSplitNumber; + const targetExtentInfo = adoptScaleExtentOptionAndPrepare(targetScale, targetAxisModel, targetDataExtent); + + // NOTE: If `dataZoom` has either start/end not 0% or 100% (indicated by `min/maxDetermined`), we consider + // both min and max fixed; otherwise the result is probably unexpected if we expand the extent out of + // the original min/max, e.g., the expanded extent may cross zero. + const hasMinMaxDetermined = targetExtentInfo.minDetermined || targetExtentInfo.maxDetermined; + const targetMinFixed = targetExtentInfo.minFixed || hasMinMaxDetermined; + const targetMaxFixed = targetExtentInfo.maxFixed || hasMinMaxDetermined; + // MEMO: + // - When only `xxxAxis.min` or `xxxAxis.max` is fixed, even "nice" interval can be calculated, ticks + // accumulated based on `min`/`max` can be "nice" only if `min` or `max` is "nice". + // - Generating a "nice" interval in this case may cause the extent have both positive and negative ticks, + // which may be not preferable for all positive (very common) or all negative series data. But it can be + // simply resolved by specifying `xxxAxis.min: 0`/`xxxAxis.max: 0`, so we do not specially handle this + // case here. + // Therefore, we prioritize generating "nice" interval over preventing from crossing zero. + // e.g., if series data are all positive and the max data is `11739`, + // If setting `yAxis.max: 'dataMax'`, ticks may be like: + // `11739, 8739, 5739, 2739, -1739` (not "nice" enough) + // If setting `yAxis.max: 'dataMax', yAxis.min: 0`, ticks may be like: + // `11739, 8805, 5870, 2935, 0` (not "nice" enough but may be acceptable) + // If setting `yAxis.max: 12000, yAxis.min: 0`, ticks may be like: + // `12000, 9000, 6000, 3000, 0` ("nice") + + let targetExtent = [targetExtentInfo.min, targetExtentInfo.max]; + if (isTargetLogScale) { + targetExtent = logTransform(targetScale.base, targetExtent); } - else if (isMinFixed) { - max = rawExtent[0] + interval * alignToSplitNumber; - // User set min, expand extent on the other side - while (max < rawExtent[1] && isFinite(max) && isFinite(rawExtent[1])) { - interval = increaseInterval(interval); - max = rawExtent[0] + interval * alignToSplitNumber; + targetExtent = intervalScaleEnsureValidExtent(targetExtent, {fixMax: targetMaxFixed}); + + let min: number; + let max: number; + let interval: number; + let intervalPrecision: number; + let intervalCount: number | NullUndefined; + let maxNice: number; + let minNice: number; + + function loopIncreaseInterval(cb: () => boolean) { + // Typically this loop runs less than 5 times. But we still + // use a safeguard for future changes. + const LOOP_MAX = 50; + let loopGuard = 0; + for (; loopGuard < LOOP_MAX; loopGuard++) { + if (cb()) { + break; + } + interval = isTargetLogScale + // TODO: A guardcode to avoid infinite loop, but probably it + // should be guranteed by `LogScale` itself. + ? interval * mathMax(targetScale.base, 2) + : increaseInterval(interval); + intervalPrecision = getIntervalPrecision(interval); } - } - else if (isMaxFixed) { - // User set max, expand extent on the other side - min = rawExtent[1] - interval * alignToSplitNumber; - while (min > rawExtent[0] && isFinite(min) && isFinite(rawExtent[0])) { - interval = increaseInterval(interval); - min = rawExtent[1] - interval * alignToSplitNumber; + if (__DEV__) { + if (loopGuard >= LOOP_MAX) { + warn('incorrect impl in `alignScaleTicks`.'); + } } } + + // NOTE: The new calculated `min`/`max` must NOT shrink the original extent; otherwise some series + // data may be outside of the extent. They can expand the original extent slightly to align with + // ticks of `alignTo`. In this case, more blank space is added but visually fine. + + if (targetMinFixed && targetMaxFixed) { + // Both `min` and `max` are specified (via dataZoom or ec option; consider both Cartesian, radar and + // other possible axes). In this case, "nice" ticks can hardly be calculated, but reasonable ticks should + // still be calculated whenever possible, especially `intervalPrecision` should be tuned for better + // appearance and lower cumulative error. + + min = targetExtent[0]; + max = targetExtent[1]; + intervalCount = alignToRegularSegCount; + const rawInterval = (max - min) / (alignToRegularSegCount + t0 + t1); + // Typically axis pixel extent is ready here. See `create` in `Grid.ts`. + const axisPxExtent = targetAxisModel.axis.getExtent(); + // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable so far. + const pxSpan = mathAbs(axisPxExtent[1] - axisPxExtent[0]); + // We imperically choose `pxDiffAcceptable` as `0.5 / alignToRegularSegCount` for reduce cumulative + // error, otherwise a discernible misalign (> 1px) may occur. + // PENDING: We do not find a acceptable precision for LogScale here. + // Theoretically it can be addressed but introduce more complexity. Is it necessary? + intervalPrecision = getAcceptableTickPrecision(max - min, pxSpan, 0.5 / alignToRegularSegCount); + interval = round(rawInterval, intervalPrecision); + maxNice = t1 ? round(max - rawInterval * t1, intervalPrecision) : max; + minNice = t0 ? round(min + rawInterval * t0, intervalPrecision) : min; + } else { - const nicedSplitNumber = scale.getTicks().length - 1; - if (nicedSplitNumber > alignToSplitNumber) { - interval = increaseInterval(interval); - } + // Make a minimal enough `interval`, increase it later. + // It is a similar logic as `IntervalScale#calcNiceTicks` and `LogScale#calcNiceTicks`. + // Axis break is not supported, which is guranteed by the caller of this function. + interval = nice((targetExtent[1] - targetExtent[0]) / alignToRegularSegCount, NICE_MODE_MIN); + intervalPrecision = getIntervalPrecision(interval); - const range = interval * alignToSplitNumber; - max = round(Math.ceil(rawExtent[1] / interval) * interval); - min = round(max - range); - // Not change the result that crossing zero. - if (min < 0 && rawExtent[0] >= 0) { - min = 0; - max = round(range); + if (targetMinFixed) { + min = targetExtent[0]; + loopIncreaseInterval(function () { + minNice = t0 ? round(min + interval * t0, intervalPrecision) : min; + maxNice = round(minNice + interval * alignToRegularSegCount, intervalPrecision); + max = round(maxNice + interval * t1, intervalPrecision); + if (max >= targetExtent[1]) { + return true; + } + }); } - else if (max > 0 && rawExtent[1] <= 0) { - max = 0; - min = -round(range); + else if (targetMaxFixed) { + max = targetExtent[1]; + loopIncreaseInterval(function () { + maxNice = t1 ? round(max - interval * t1, intervalPrecision) : max; + minNice = round(maxNice - interval * alignToRegularSegCount, intervalPrecision); + min = round(minNice - interval * t0, intervalPrecision); + if (min <= targetExtent[0]) { + return true; + } + }); + } + else { + // Currently we simply lay out ticks of the target scale to the "regular segments" of `alignTo` + // scale for "nice". If unexpected cases occur in future, the strategy can be tuned precisely + // (e.g., make use of irregular segments). + loopIncreaseInterval(function () { + // Consider cases that all positive or all negative, try not to cross zero, which is + // preferable in most cases. + if (targetExtent[1] <= 0) { + maxNice = round(mathCeil(targetExtent[1] / interval) * interval, intervalPrecision); + minNice = round(maxNice - interval * alignToRegularSegCount, intervalPrecision); + if (minNice <= targetExtent[0]) { + return true; + } + } + else { + minNice = round(mathFloor(targetExtent[0] / interval) * interval, intervalPrecision); + maxNice = round(minNice + interval * alignToRegularSegCount, intervalPrecision); + if (maxNice >= targetExtent[1]) { + return true; + } + } + }); + min = round(minNice - interval * t0, intervalPrecision); + max = round(maxNice + interval * t1, intervalPrecision); } + intervalPrecision = null; // Clear for the calling of `setInterval`. } - // Adjust min, max based on the extent of alignTo. When min or max is set in alignTo scale - const t0 = (alignToTicks[0].value - alignToNicedTicks[0].value) / alignToInterval; - const t1 = (alignToTicks[alignToSplitNumber].value - alignToNicedTicks[alignToSplitNumber].value) / alignToInterval; - - // NOTE: Must in setExtent -> setInterval -> setNiceExtent order. - intervalScaleProto.setExtent.call(scale, min + interval * t0, max + interval * t1); - intervalScaleProto.setInterval.call(scale, interval); - if (t0 || t1) { - intervalScaleProto.setNiceExtent.call(scale, min + interval, max - interval); - } - - if (__DEV__) { - const ticks = intervalScaleProto.getTicks.call(scale); - if (ticks[1] - && (!isValueNice(interval) || getPrecisionSafe(ticks[1].value) > getPrecisionSafe(interval))) { - warn( - `The ticks may be not readable when set min: ${axisModel.get('min')}, max: ${axisModel.get('max')}` - + ` and alignTicks: true. (${axisModel.axis?.dim}AxisIndex: ${axisModel.componentIndex})`, - true - ); - } + if (isTargetLogScale) { + min = targetScale.powTick(min, 0, null); + max = targetScale.powTick(max, 1, null); } + // NOTE: Must in setExtent -> setInterval order. + targetScale.setExtent(min, max); + targetScale.setInterval({ + // Even in LogScale, `interval` should not be in log space. + interval, + intervalCount, + intervalPrecision, + niceExtent: [minNice, maxNice] + }); } diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 4291a0729c..944ad59491 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -210,10 +210,10 @@ export interface ValueAxisBaseOption extends NumericAxisBaseOptionCommon { /** * Optional value can be: - * + `false`: always include value 0. + * + `false`: always include value 0 if not conflict with `axis.min/max` setting. * + `true`: the axis may not contain zero position. */ - scale?: boolean; + scale?: boolean; } export interface LogAxisBaseOption extends NumericAxisBaseOptionCommon { type?: 'log'; diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 24cb773ee3..301bab7a95 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -46,26 +46,35 @@ import CartesianAxisModel from './cartesian/AxisModel'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; import { Dictionary, DimensionName, ScaleTick } from '../util/types'; -import { ensureScaleRawExtentInfo } from './scaleRawExtentInfo'; +import { ensureScaleRawExtentInfo, ScaleRawExtentResult } from './scaleRawExtentInfo'; import { parseTimeAxisLabelFormatter } from '../util/time'; import { getScaleBreakHelper } from '../scale/break'; import { error } from '../util/log'; +import { isIntervalScale, isTimeScale } from '../scale/helper'; type BarWidthAndOffset = ReturnType; /** - * Get axis scale extent before niced. + * Prepare axis scale extent before niced. * Item of returned array can only be number (including Infinity and NaN). * - * Caution: - * Precondition of calling this method: - * The scale extent has been initialized using series data extent via - * `scale.setExtent` or `scale.unionExtentFromData`; + * CAVEAT: + * This function has side-effect. + * + * FIXME: + * Refector to decouple `unionExtentFromData` and irregular value handling from `scale`. + * Merge `unionAxisExtentFromData` and `unionExtentFromData`. + * Refector `ensureScaleRawExtentInfo`. */ -export function getScaleExtent(scale: Scale, model: AxisBaseModel) { - const scaleType = scale.type; - const rawExtentResult = ensureScaleRawExtentInfo(scale, model, scale.getExtent()).calculate(); +export function adoptScaleExtentOptionAndPrepare( + scale: Scale, + model: AxisBaseModel, + // Typically: data extent from all series on this axis. + // Can be obtained by `scale.unionExtentFromData(); scale.getExtent()`; + dataExtent: number[] +): ScaleRawExtentResult { + const rawExtentResult = ensureScaleRawExtentInfo(scale, model, dataExtent).calculate(); scale.setBlank(rawExtentResult.isBlank); @@ -82,7 +91,7 @@ export function getScaleExtent(scale: Scale, model: AxisBaseModel) { // (4) Consider other chart types using `barGrid`? // See #6728, #4862, `test/bar-overflow-time-plot.html` const ecModel = model.ecModel; - if (ecModel && (scaleType === 'time' /* || scaleType === 'interval' */)) { + if (ecModel && (isTimeScale(scale) /* || scaleType === 'interval' */)) { const barSeriesModels = prepareLayoutBarSeries('bar', ecModel); let isBaseAxisAndHasBarSeries = false; @@ -102,13 +111,10 @@ export function getScaleExtent(scale: Scale, model: AxisBaseModel) { } } - return { - extent: [min, max], - // "fix" means "fixed", the value should not be - // changed in the subsequent steps. - fixMin: rawExtentResult.minFixed, - fixMax: rawExtentResult.maxFixed - }; + rawExtentResult.min = min; + rawExtentResult.max = max; + + return rawExtentResult; } function adjustScaleForOverflow( @@ -151,32 +157,25 @@ function adjustScaleForOverflow( return {min: min, max: max}; } -// Precondition of calling this method: -// The scale extent has been initialized using series data extent via -// `scale.setExtent` or `scale.unionExtentFromData`; export function niceScaleExtent( scale: Scale, - inModel: AxisBaseModel -) { + inModel: AxisBaseModel, + // Typically: data extent from all series on this axis, which can be obtained by + // `scale.unionExtentFromData(...); scale.getExtent();`. + dataExtent: number[], +): void { const model = inModel as AxisBaseModel; - const extentInfo = getScaleExtent(scale, model); - const extent = extentInfo.extent; - const splitNumber = model.get('splitNumber'); - - if (scale instanceof LogScale) { - scale.base = model.get('logBase'); - } + const extentInfo = adoptScaleExtentOptionAndPrepare(scale, model, dataExtent); - const scaleType = scale.type; - const interval = model.get('interval'); - const isIntervalOrTime = scaleType === 'interval' || scaleType === 'time'; + const isInterval = isIntervalScale(scale); + const isIntervalOrTime = isInterval || isTimeScale(scale); scale.setBreaksFromOption(retrieveAxisBreaksOption(model)); - scale.setExtent(extent[0], extent[1]); + scale.setExtent(extentInfo.min, extentInfo.max); scale.calcNiceExtent({ - splitNumber: splitNumber, - fixMin: extentInfo.fixMin, - fixMax: extentInfo.fixMax, + splitNumber: model.get('splitNumber'), + fixMin: extentInfo.minFixed, + fixMax: extentInfo.maxFixed, minInterval: isIntervalOrTime ? model.get('minInterval') : null, maxInterval: isIntervalOrTime ? model.get('maxInterval') : null }); @@ -185,36 +184,35 @@ export function niceScaleExtent( // is not good enough. He can specify the interval. It is often appeared // in angle axis with angle 0 - 360. Interval calculated in interval scale is hard // to be 60. - // FIXME - if (interval != null) { - (scale as IntervalScale).setInterval && (scale as IntervalScale).setInterval(interval); + // In `xxxAxis.type: 'log'`, ec option `xxxAxis.interval` requires a logarithm-applied + // value rather than a value in the raw scale. + const interval = model.get('interval'); + if (interval != null && (scale as IntervalScale).setInterval) { + (scale as IntervalScale).setInterval({interval}); } } -/** - * @param axisType Default retrieve from model.type - */ -export function createScaleByModel(model: AxisBaseModel, axisType?: string): Scale { - axisType = axisType || model.get('type'); - if (axisType) { - switch (axisType) { - // Buildin scale - case 'category': - return new OrdinalScale({ - ordinalMeta: model.getOrdinalMeta - ? model.getOrdinalMeta() - : model.getCategories(), - extent: [Infinity, -Infinity] - }); - case 'time': - return new TimeScale({ - locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC'), - }); - default: - // case 'value'/'interval', 'log', or others. - return new (Scale.getClass(axisType) || IntervalScale)(); - } +export function createScaleByModel(model: AxisBaseModel): Scale { + const axisType = model.get('type'); + switch (axisType) { + case 'category': + return new OrdinalScale({ + ordinalMeta: model.getOrdinalMeta + ? model.getOrdinalMeta() + : model.getCategories(), + extent: [Infinity, -Infinity] + }); + case 'time': + return new TimeScale({ + locale: model.ecModel.getLocaleModel(), + useUTC: model.ecModel.get('useUTC'), + }); + case 'log': + // See also #3749 + return new LogScale((model as AxisBaseModel).get('logBase')); + default: + // case 'value'/'interval', or others. + return new (Scale.getClass(axisType) || IntervalScale)(); } } @@ -303,7 +301,6 @@ export function getAxisRawValue(axis: Axis, tick: S /** * @param model axisLabelModel or axisTickModel - * @return {number|String} Can be null|'auto'|number|function */ export function getOptionCategoryInterval( model: Model diff --git a/src/coord/axisModelCommonMixin.ts b/src/coord/axisModelCommonMixin.ts index 57adacfb1b..ed8b7c45a2 100644 --- a/src/coord/axisModelCommonMixin.ts +++ b/src/coord/axisModelCommonMixin.ts @@ -31,8 +31,7 @@ interface AxisModelCommonMixin extends Pick { getNeedCrossZero(): boolean { - const option = this.option as ValueAxisBaseOption; - return !option.scale; + return !(this.option as ValueAxisBaseOption).scale; } /** diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 97ac081bf0..2b695c3dfe 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -32,6 +32,7 @@ import { getDataDimensionsOnAxis, isNameLocationCenter, shouldAxisShow, + retrieveAxisBreaksOption, } from '../../coord/axisHelper'; import Cartesian2D, {cartesian2DDimensions} from './Cartesian2D'; import Axis2D from './Axis2D'; @@ -124,50 +125,32 @@ class Grid implements CoordinateSystemMaster { this._updateScale(ecModel, this.model); function updateAxisTicks(axes: Record) { - let alignTo: Axis2D; // Axis is added in order of axisIndex. const axesIndices = keys(axes); - const len = axesIndices.length; - if (!len) { - return; - } const axisNeedsAlign: Axis2D[] = []; - // Process once and calculate the ticks for those don't use alignTicks. - for (let i = len - 1; i >= 0; i--) { - const idx = +axesIndices[i]; // Convert to number. - const axis = axes[idx]; - const model = axis.model as AxisBaseModel; - const scale = axis.scale; - if (// Only value and log axis without interval support alignTicks. - isIntervalOrLogScale(scale) - && model.get('alignTicks') - && model.get('interval') == null - ) { + + for (let i = axesIndices.length - 1; i >= 0; i--) { // Reverse order + const axis = axes[+axesIndices[i]]; + if (axis.alignTo) { axisNeedsAlign.push(axis); } else { - niceScaleExtent(scale, model); - if (isIntervalOrLogScale(scale)) { // Can only align to interval or log axis. - alignTo = axis; - } + niceScaleExtent(axis.scale, axis.model, axis.scale.getExtent()); } }; - // All axes has set alignTicks. Pick the first one. - // PENDING. Should we find the axis that both set interval, min, max and align to this one? - if (axisNeedsAlign.length) { - if (!alignTo) { - alignTo = axisNeedsAlign.pop(); - niceScaleExtent(alignTo.scale, alignTo.model); + each(axisNeedsAlign, axis => { + if (incapableOfAlignNeedFallback(axis, axis.alignTo as Axis2D)) { + niceScaleExtent(axis.scale, axis.model, axis.scale.getExtent()); } - - each(axisNeedsAlign, axis => { + else { alignScaleTicks( axis.scale as IntervalScale | LogScale, + axis.scale.getExtent(), axis.model, - alignTo.scale as IntervalScale | LogScale + axis.alignTo.scale as IntervalScale | LogScale ); - }); - } + } + }); } updateAxisTicks(axesMap.x); @@ -450,6 +433,9 @@ class Grid implements CoordinateSystemMaster { }); }); + prepareAlignToInCoordSysCreate(axesMap.x); + prepareAlignToInCoordSysCreate(axesMap.y); + function createAxisCreator(dimName: Cartesian2DDimensionName) { return function (axisModel: CartesianAxisModel, idx: number): void { if (!isAxisUsedInTheGrid(axisModel, gridModel)) { @@ -698,6 +684,66 @@ function canOnZeroToAxis(axis: Axis2D): boolean { return axis && axis.type !== 'category' && axis.type !== 'time' && ifAxisCrossZero(axis); } +/** + * [CAVEAT] This method is called before data processing stage. + * Do not rely on any info that is determined afterward. + */ +function prepareAlignToInCoordSysCreate(axes: Record): void { + // Axis is added in order of axisIndex. + const axesIndices = keys(axes); + + let alignTo: Axis2D; + const axisNeedsAlign: Axis2D[] = []; + + for (let i = axesIndices.length - 1; i >= 0; i--) { // Reverse order + const axis = axes[+axesIndices[i]]; + if ( + isIntervalOrLogScale(axis.scale) + // NOTE: `scale.hasBreaks()` is not available at this moment. Check it later. + && retrieveAxisBreaksOption(axis.model) == null + // NOTE: `scale.getTicks()` is not available at this moment. Check it later. + ) { + // Request `alignTicks`. + if ((axis.model as AxisBaseModel).get('alignTicks') + && (axis.model as AxisBaseModel).get('interval') == null + ) { + axisNeedsAlign.push(axis); + } + else { + // `alignTo` the last one that does not request `alignTicks` + // (This rule is retained for backward compat). + alignTo = axis; + } + } + }; + // If all axes has set alignTicks, pick the first one as alignTo. + // PENDING. Should we find the axis that both set interval, min, max and align to this one? + // PENDING. Should we allow specifying alignTo via ec option? + if (!alignTo) { + alignTo = axisNeedsAlign.pop(); + } + if (alignTo) { + each(axisNeedsAlign, function (axis) { + axis.alignTo = alignTo; + }); + } +} + +/** + * This is just a defence code. They are unlikely to be actually `true`, + * since these cases have been addressed in `prepareAlignToInCoordSysCreate`. + * + * Can not be called BEFORE "nice" performed. + */ +function incapableOfAlignNeedFallback(targetAxis: Axis2D, alignTo: Axis2D): boolean { + return targetAxis.scale.hasBreaks() + || alignTo.scale.hasBreaks() + // Normally ticks length are more than 2 even when axis is blank. + // But still guard for corner cases and possible changes. + || alignTo.scale.getTicks().length < 2; +} + + function updateAxisTransform(axis: Axis2D, coordBase: number) { const axisExtent = axis.getExtent(); const axisExtentSum = axisExtent[0] + axisExtent[1]; diff --git a/src/coord/cartesian/defaultAxisExtentFromData.ts b/src/coord/cartesian/defaultAxisExtentFromData.ts index ca4bdcbacf..76e0cdac97 100644 --- a/src/coord/cartesian/defaultAxisExtentFromData.ts +++ b/src/coord/cartesian/defaultAxisExtentFromData.ts @@ -241,7 +241,7 @@ function shrinkAxisExtent(axisRecordMap: HashMap) { if (tarAxisExtent) { const rawExtentResult = axisRecord.rawExtentResult; const rawExtentInfo = axisRecord.rawExtentInfo; - // Shink the original extent. + // Shrink the original extent. if (!rawExtentResult.minFixed && tarAxisExtent[0] > rawExtentResult.min) { rawExtentInfo.modifyDataMinMax('min', tarAxisExtent[0]); } diff --git a/src/coord/parallel/Parallel.ts b/src/coord/parallel/Parallel.ts index 6566cb1215..38ad006caa 100644 --- a/src/coord/parallel/Parallel.ts +++ b/src/coord/parallel/Parallel.ts @@ -23,13 +23,13 @@ * */ -import * as zrUtil from 'zrender/src/core/util'; +import {each, createHashMap, clone} from 'zrender/src/core/util'; import * as matrix from 'zrender/src/core/matrix'; import * as layoutUtil from '../../util/layout'; import * as axisHelper from '../../coord/axisHelper'; import ParallelAxis from './ParallelAxis'; import * as graphic from '../../util/graphic'; -import * as numberUtil from '../../util/number'; +import {mathCeil, mathFloor, mathMax, mathMin, mathPI, round} from '../../util/number'; import sliderMove from '../../component/helper/sliderMove'; import ParallelModel, { ParallelLayoutDirection } from './ParallelModel'; import GlobalModel from '../../model/Global'; @@ -41,13 +41,6 @@ import SeriesData from '../../data/SeriesData'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; -const each = zrUtil.each; -const mathMin = Math.min; -const mathMax = Math.max; -const mathFloor = Math.floor; -const mathCeil = Math.ceil; -const round = numberUtil.round; -const PI = Math.PI; interface ParallelCoordinateSystemLayoutInfo { layout: ParallelLayoutDirection; @@ -85,7 +78,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { /** * key: dimension */ - private _axesMap = zrUtil.createHashMap(); + private _axesMap = createHashMap(); /** * key: dimension @@ -191,7 +184,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { // do after all series processed each(this.dimensions, function (dim) { const axis = this._axesMap.get(dim); - axisHelper.niceScaleExtent(axis.scale, axis.model); + axisHelper.niceScaleExtent(axis.scale, axis.model, axis.scale.getExtent()); }, this); } @@ -304,7 +297,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { } }; const rotationTable = { - horizontal: PI / 2, + horizontal: mathPI / 2, vertical: 0 }; @@ -373,7 +366,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { const dataDimensions = [] as DimensionName[]; const axisModels = [] as ParallelAxisModel[]; - zrUtil.each(dimensions, function (axisDim) { + each(dimensions, function (axisDim) { dataDimensions.push(data.mapDimension(axisDim)); axisModels.push(axesMap.get(axisDim).model); }); @@ -433,7 +426,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { * Get axis layout. */ getAxisLayout(dim: DimensionName): ParallelAxisLayoutInfo { - return zrUtil.clone(this._axesLayout[dim]); + return clone(this._axesLayout[dim]); } /** diff --git a/src/coord/polar/polarCreator.ts b/src/coord/polar/polarCreator.ts index 224de9c3b0..8a7515896d 100644 --- a/src/coord/polar/polarCreator.ts +++ b/src/coord/polar/polarCreator.ts @@ -81,24 +81,26 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) const polar = this; const angleAxis = polar.getAngleAxis(); const radiusAxis = polar.getRadiusAxis(); + const angleScale = angleAxis.scale; + const radiusScale = radiusAxis.scale; // Reset scale - angleAxis.scale.setExtent(Infinity, -Infinity); - radiusAxis.scale.setExtent(Infinity, -Infinity); + angleScale.setExtent(Infinity, -Infinity); + radiusScale.setExtent(Infinity, -Infinity); ecModel.eachSeries(function (seriesModel) { if (seriesModel.coordinateSystem === polar) { const data = seriesModel.getData(); zrUtil.each(getDataDimensionsOnAxis(data, 'radius'), function (dim) { - radiusAxis.scale.unionExtentFromData(data, dim); + radiusScale.unionExtentFromData(data, dim); }); zrUtil.each(getDataDimensionsOnAxis(data, 'angle'), function (dim) { - angleAxis.scale.unionExtentFromData(data, dim); + angleScale.unionExtentFromData(data, dim); }); } }); - niceScaleExtent(angleAxis.scale, angleAxis.model); - niceScaleExtent(radiusAxis.scale, radiusAxis.model); + niceScaleExtent(angleScale, angleAxis.model, angleScale.getExtent()); + niceScaleExtent(radiusScale, radiusAxis.model, radiusScale.getExtent()); // Fix extent of category angle axis if (angleAxis.type === 'category' && !angleAxis.onBand) { diff --git a/src/coord/radar/Radar.ts b/src/coord/radar/Radar.ts index c0fb4df295..02d0465854 100644 --- a/src/coord/radar/Radar.ts +++ b/src/coord/radar/Radar.ts @@ -174,11 +174,12 @@ class Radar implements CoordinateSystem, CoordinateSystemMaster { const splitNumber = radarModel.get('splitNumber'); const dummyScale = new IntervalScale(); dummyScale.setExtent(0, splitNumber); - dummyScale.setInterval(1); + dummyScale.setInterval({interval: 1}); // Force all the axis fixing the maxSplitNumber. each(indicatorAxes, function (indicatorAxis, idx) { alignScaleTicks( indicatorAxis.scale as IntervalScale, + indicatorAxis.scale.getExtent(), indicatorAxis.model, dummyScale ); diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 389b98c3b1..8ff4e78a0e 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -21,8 +21,11 @@ import { assert, isArray, eqNaN, isFunction } from 'zrender/src/core/util'; import Scale from '../scale/Scale'; import { AxisBaseModel } from './AxisBaseModel'; import { parsePercent } from 'zrender/src/contain/text'; -import { AxisBaseOption, CategoryAxisBaseOption, NumericAxisBaseOptionCommon } from './axisCommonTypes'; +import { + AxisBaseOption, CategoryAxisBaseOption, NumericAxisBaseOptionCommon, ValueAxisBaseOption +} from './axisCommonTypes'; import { ScaleDataValue } from '../util/types'; +import { isIntervalScale, isLogScale, isOrdinalScale, isTimeScale } from '../scale/helper'; export interface ScaleRawExtentResult { @@ -32,19 +35,27 @@ export interface ScaleRawExtentResult { // a little (say, "nice strategy", e.g., niceScale, boundaryGap). // Ensure `min`/`max` be finite number or NaN here. // (not to be null/undefined) `NaN` means min/max axis is blank. - readonly min: number; - readonly max: number; - // `minFixed`/`maxFixed` marks that `min`/`max` should be used - // in the final extent without other "nice strategy". + min: number; + max: number; + + // `minFixed`/`maxFixed` marks that: + // - `xxxAxis.min/max` are user specified, or + // - `minDetermined/maxDetermined` are `true` + // so it should be used directly in the final extent without any other "nice strategy". readonly minFixed: boolean; readonly maxFixed: boolean; + + // Typically set by `dataZoom` when its start/end is not 0%/100%. + readonly minDetermined: boolean; + readonly maxDetermined: boolean; + // Mark that the axis should be blank. readonly isBlank: boolean; } export class ScaleRawExtentInfo { - private _needCrossZero: boolean; + private _needCrossZero: ValueAxisBaseOption['scale']; private _isOrdinal: boolean; private _axisDataLen: number; private _boundaryGapInner: number[]; @@ -62,6 +73,7 @@ export class ScaleRawExtentInfo { private _dataMin: number; private _dataMax: number; + // Typically specified by `dataZoom` when its start/end is not 0%/100%. // Highest priority if specified. private _determinedMin: number; private _determinedMax: number; @@ -76,10 +88,10 @@ export class ScaleRawExtentInfo { constructor( scale: Scale, model: AxisBaseModel, - // Usually: data extent from all series on this axis. - originalExtent: number[] + // Typically: data extent from all series on this axis. + dataExtent: number[] ) { - this._prepareParams(scale, model, originalExtent); + this._prepareParams(scale, model, dataExtent); } /** @@ -98,10 +110,10 @@ export class ScaleRawExtentInfo { this._dataMin = dataExtent[0]; this._dataMax = dataExtent[1]; - const isOrdinal = this._isOrdinal = scale.type === 'ordinal'; - this._needCrossZero = scale.type === 'interval' && model.getNeedCrossZero && model.getNeedCrossZero(); + const isOrdinal = this._isOrdinal = isOrdinalScale(scale); + this._needCrossZero = isIntervalScale(scale) && model.getNeedCrossZero && model.getNeedCrossZero(); - if (scale.type === 'interval' || scale.type === 'log' || scale.type === 'time') { + if (isIntervalScale(scale) || isLogScale(scale) || isTimeScale(scale)) { // Process custom dataMin/dataMax const dataMinRaw = (model as AxisBaseModel).get('dataMin', true); if (dataMinRaw != null) { @@ -255,15 +267,22 @@ export class ScaleRawExtentInfo { // If so, here `minFixed`/`maxFixed` need to be set. } + // NOTE: Switching `min/maxFixed` probably leads to abrupt extent changes when draging a `dataZoom` + // handle, since minFixed/maxFixed impact the "nice extent" and "nice ticks" calculation. Consider + // the case that dataZoom `start` is greater than 0% but its `end` is 100%, (or vice versa), we + // currently only set `minFixed` as `true` but remain `maxFixed` as `false` to avoid unnecessary + // abrupt change. Incidentally, the effect is not unacceptable if we set both `min/maxFixed` as `true`. const determinedMin = this._determinedMin; const determinedMax = this._determinedMax; + let minDetermined = false; + let maxDetermined = false; if (determinedMin != null) { min = determinedMin; - minFixed = true; + minFixed = minDetermined = true; } if (determinedMax != null) { max = determinedMax; - maxFixed = true; + maxFixed = maxDetermined = true; } // Ensure min/max be finite number or NaN here. (not to be null/undefined) @@ -273,7 +292,9 @@ export class ScaleRawExtentInfo { max: max, minFixed: minFixed, maxFixed: maxFixed, - isBlank: isBlank + minDetermined: minDetermined, + maxDetermined: maxDetermined, + isBlank: isBlank, }; } @@ -323,8 +344,11 @@ const DATA_MIN_MAX_ATTR = { min: '_dataMin', max: '_dataMax' } as const; export function ensureScaleRawExtentInfo( scale: Scale, model: AxisBaseModel, - // Usually: data extent from all series on this axis. - originalExtent: number[] + // Typically: data extent from all series on this axis. + // FIXME: + // Refactor: only the first input `dataExtent` is used but it is determined by the + // caller, which is error-prone. + dataExtent: number[] ): ScaleRawExtentInfo { // Do not permit to recreate. @@ -333,7 +357,7 @@ export function ensureScaleRawExtentInfo( return rawExtentInfo; } - rawExtentInfo = new ScaleRawExtentInfo(scale, model, originalExtent); + rawExtentInfo = new ScaleRawExtentInfo(scale, model, dataExtent); // @ts-ignore scale.rawExtentInfo = rawExtentInfo; diff --git a/src/coord/single/Single.ts b/src/coord/single/Single.ts index 80e2ac04c2..5610fe7d25 100644 --- a/src/coord/single/Single.ts +++ b/src/coord/single/Single.ts @@ -99,10 +99,12 @@ class Single implements CoordinateSystem, CoordinateSystemMaster { ecModel.eachSeries(function (seriesModel) { if (seriesModel.coordinateSystem === this) { const data = seriesModel.getData(); + const axis = this._axis; + const scale = axis.scale; each(data.mapDimensionsAll(this.dimension), function (dim) { - this._axis.scale.unionExtentFromData(data, dim); - }, this); - axisHelper.niceScaleExtent(this._axis.scale, this._axis.model); + scale.unionExtentFromData(data, dim); + }); + axisHelper.niceScaleExtent(scale, axis.model, scale.getExtent()); } }, this); } diff --git a/src/export/api/helper.ts b/src/export/api/helper.ts index 92a4ddf66a..7ef631e78e 100644 --- a/src/export/api/helper.ts +++ b/src/export/api/helper.ts @@ -96,19 +96,12 @@ export function createScale(dataExtent: number[], option: object | AxisBaseModel const scale = axisHelper.createScaleByModel(axisModel as AxisBaseModel); scale.setExtent(dataExtent[0], dataExtent[1]); - axisHelper.niceScaleExtent(scale, axisModel as AxisBaseModel); + axisHelper.niceScaleExtent(scale, axisModel as AxisBaseModel, scale.getExtent()); return scale; } /** - * Mixin common methods to axis model, - * - * Include methods - * `getFormattedLabels() => Array.` - * `getCategories() => Array.` - * `getMin(origin: boolean) => number` - * `getMax(origin: boolean) => number` - * `getNeedCrossZero() => boolean` + * Mixin common methods to axis model */ export function mixinAxisModelCommonMethods(Model: Model) { zrUtil.mixin(Model, AxisModelCommonMixin); diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index 917aa3f531..98ca9daec0 100644 --- a/src/scale/Interval.ts +++ b/src/scale/Interval.ts @@ -18,14 +18,13 @@ */ -import * as numberUtil from '../util/number'; -import * as formatUtil from '../util/format'; +import {round, mathRound, mathMin, getPrecision, mathCeil, mathFloor} from '../util/number'; +import {addCommas} from '../util/format'; import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; import * as helper from './helper'; -import {ScaleTick, ParsedAxisBreakList, ScaleDataValue} from '../util/types'; +import {ScaleTick, ParsedAxisBreakList, ScaleDataValue, NullUndefined} from '../util/types'; import { getScaleBreakHelper } from './break'; - -const roundNumber = numberUtil.round; +import { assert } from 'zrender/src/core/util'; class IntervalScale extends Scale { @@ -34,8 +33,15 @@ class IntervalScale e // Step is calculated in adjustExtent. protected _interval: number = 0; - protected _niceExtent: [number, number]; protected _intervalPrecision: number = 2; + // `_intervalCount` effectively specifies the number of "nice segment". This is for special cases, + // such as `alignTo: true` and min max are fixed. In this case, `_interval` may be specified with + // a "not-nice" value and needs to be rounded with `_intervalPrecision` for better appearance. Then + // merely accumulating `_interval` may generate incorrect number of ticks. So `_intervalCount` is + // required to specify the expected tick number. + private _intervalCount: number | NullUndefined = undefined; + // Should ensure: `_extent[0] <= _niceExtent[0] && _niceExtent[1] <= _extent[1]` + protected _niceExtent: [number, number]; parse(val: ScaleDataValue): number { @@ -81,16 +87,61 @@ class IntervalScale e return this._interval; } - setInterval(interval: number): void { + /** + * @final override is DISALLOWED. + */ + setInterval({interval, intervalCount, intervalPrecision, niceExtent}: { + interval?: number | NullUndefined; + intervalCount?: number | NullUndefined; + intervalPrecision?: number | NullUndefined; + niceExtent?: number[]; + }): void { + const intervalCountSpecified = intervalCount != null; + if (__DEV__) { + assert(interval != null); + if (intervalCountSpecified) { + assert( + intervalCount > 0 + && intervalPrecision != null + // Do not support intervalCount on axis break currently. + && !this.hasBreaks() + ); + } + } + + const extent = this._extent; + if (__DEV__) { + if (niceExtent != null) { + assert( + isFinite(niceExtent[0]) && isFinite(niceExtent[1]) + && extent[0] <= niceExtent[0] && niceExtent[1] <= extent[1] + ); + } + } + niceExtent = this._niceExtent = niceExtent != null + ? niceExtent.slice() as [number, number] + // Dropped the auto calculated niceExtent and use user-set extent. + // We assume users want to set both interval and extent to get a better result. + : extent.slice() as [number, number]; + this._interval = interval; - // Dropped auto calculated niceExtent and use user-set extent. - // We assume user wants to set both interval, min, max to get a better result. - this._niceExtent = this._extent.slice() as [number, number]; - this._intervalPrecision = helper.getIntervalPrecision(interval); + if (!intervalCountSpecified) { + // This is for cases of "nice" interval. + this._intervalCount = undefined; // Clear + this._intervalPrecision = helper.getIntervalPrecision(interval); + } + else { + // This is for cases of "not-nice" interval, typically min max are fixed and + // axis alignment is required. + this._intervalCount = intervalCount; + this._intervalPrecision = intervalPrecision; + } } /** + * In ascending order. + * * @override */ getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { @@ -112,13 +163,15 @@ class IntervalScale e return ticks; } + // [CAVEAT]: If changing this logic, must sync it to `axisAlignTicks.ts`. + // Consider this case: using dataZoom toolbox, zoom and zoom. const safeLimit = 10000; if (extent[0] < niceTickExtent[0]) { if (opt.expandToNicedExtent) { ticks.push({ - value: roundNumber(niceTickExtent[0] - interval, intervalPrecision) + value: round(niceTickExtent[0] - interval, intervalPrecision) }); } else { @@ -129,21 +182,43 @@ class IntervalScale e } const estimateNiceMultiple = (tickVal: number, targetTick: number) => { - return Math.round((targetTick - tickVal) / interval); + return mathRound((targetTick - tickVal) / interval); }; - let tick = niceTickExtent[0]; - while (tick <= niceTickExtent[1]) { + const intervalCount = this._intervalCount; + for ( + let tick = niceTickExtent[0], niceTickIdx = 0; + ; + niceTickIdx++ + ) { + if (intervalCount == null) { + if (tick > niceTickExtent[1]) { + break; + } + } + else { + if (niceTickIdx > intervalCount) { // ticks number should be `intervalCount + 1` + break; + } + // Consider cumulative error, especially caused by rounding, the last nice + // `tick` may be less than or greater than `niceTickExtent[1]` slightly. + tick = mathMin(tick, niceTickExtent[1]); + if (niceTickIdx === intervalCount) { + tick = niceTickExtent[1]; + } + } + ticks.push({ value: tick }); // Avoid rounding error - tick = roundNumber(tick + interval, intervalPrecision); + tick = round(tick + interval, intervalPrecision); + if (this._brkCtx) { const moreMultiple = this._brkCtx.calcNiceTickMultiple(tick, estimateNiceMultiple); if (moreMultiple >= 0) { - tick = roundNumber(tick + moreMultiple * interval, intervalPrecision); + tick = round(tick + moreMultiple * interval, intervalPrecision); } } @@ -156,13 +231,14 @@ class IntervalScale e return []; } } + // Consider this case: the last item of ticks is smaller // than niceTickExtent[1] and niceTickExtent[1] === extent[1]. const lastNiceTick = ticks.length ? ticks[ticks.length - 1].value : niceTickExtent[1]; if (extent[1] > lastNiceTick) { if (opt.expandToNicedExtent) { ticks.push({ - value: roundNumber(lastNiceTick + interval, intervalPrecision) + value: round(lastNiceTick + interval, intervalPrecision) }); } else { @@ -216,7 +292,7 @@ class IntervalScale e const minorIntervalPrecision = helper.getIntervalPrecision(minorInterval); while (count < splitNumber - 1) { - const minorTick = roundNumber(prevTick.value + (count + 1) * minorInterval, minorIntervalPrecision); + const minorTick = round(prevTick.value + (count + 1) * minorInterval, minorIntervalPrecision); // For the first and last interval. The count may be less than splitNumber. if (minorTick > extent[0] && minorTick < extent[1]) { @@ -262,7 +338,7 @@ class IntervalScale e let precision = opt && opt.precision; if (precision == null) { - precision = numberUtil.getPrecision(data.value) || 0; + precision = getPrecision(data.value) || 0; } else if (precision === 'auto') { // Should be more precise then tick. @@ -270,10 +346,10 @@ class IntervalScale e } // (1) If `precision` is set, 12.005 should be display as '12.00500'. - // (2) Use roundNumber (toFixed) to avoid scientific notation like '3.5e-7'. - const dataNum = roundNumber(data.value, precision as number, true); + // (2) Use `round` (toFixed) to avoid scientific notation like '3.5e-7'. + const dataNum = round(data.value, precision as number, true); - return formatUtil.addCommas(dataNum); + return addCommas(dataNum); } /** @@ -286,12 +362,16 @@ class IntervalScale e * @param splitNumber By default `5`. */ calcNiceTicks(splitNumber?: number, minInterval?: number, maxInterval?: number): void { - splitNumber = splitNumber || 5; + splitNumber = helper.ensureValidSplitNumber(splitNumber, 5); let extent = this._extent.slice() as [number, number]; let span = this._getExtentSpanWithBreaks(); + if (!isFinite(span)) { + // FIXME: Check and refactor this branch -- this return should never happen; + // otherwise the subsequent logic may be incorrect. return; } + // User may set axis min 0 and data are all negative // FIXME If it needs to reverse ? if (span < 0) { @@ -310,43 +390,21 @@ class IntervalScale e this._niceExtent = result.niceTickExtent; } + /** + * FIXME: refactor - disallow override for readability; use composition instead. + * `calcNiceExtent` and `alignScaleTicks` both implement tick arrangement (for + * two scenarios), but they are implemented in two different code styles. + */ calcNiceExtent(opt: { splitNumber: number, // By default 5. + // Do not modify the original extent[0]/extent[1] except for an invalid extent. fixMin?: boolean, fixMax?: boolean, minInterval?: number, maxInterval?: number }): void { - let extent = this._extent.slice() as [number, number]; - // If extent start and end are same, expand them - if (extent[0] === extent[1]) { - if (extent[0] !== 0) { - // Expand extent - // Note that extents can be both negative. See #13154 - const expandSize = Math.abs(extent[0]); - // In the fowllowing case - // Axis has been fixed max 100 - // Plus data are all 100 and axis extent are [100, 100]. - // Extend to the both side will cause expanded max is larger than fixed max. - // So only expand to the smaller side. - if (!opt.fixMax) { - extent[1] += expandSize / 2; - extent[0] -= expandSize / 2; - } - else { - extent[0] -= expandSize / 2; - } - } - else { - extent[1] = 1; - } - } - const span = extent[1] - extent[0]; - // If there are no data and extent are [Infinity, -Infinity] - if (!isFinite(span)) { - extent[0] = 0; - extent[1] = 1; - } + let extent = helper.intervalScaleEnsureValidExtent(this._extent, opt); + this._innerSetExtent(extent[0], extent[1]); extent = this._extent.slice() as [number, number]; @@ -355,16 +413,14 @@ class IntervalScale e const intervalPrecition = this._intervalPrecision; if (!opt.fixMin) { - extent[0] = roundNumber(Math.floor(extent[0] / interval) * interval, intervalPrecition); + extent[0] = round(mathFloor(extent[0] / interval) * interval, intervalPrecition); } if (!opt.fixMax) { - extent[1] = roundNumber(Math.ceil(extent[1] / interval) * interval, intervalPrecition); + extent[1] = round(mathCeil(extent[1] / interval) * interval, intervalPrecition); } this._innerSetExtent(extent[0], extent[1]); - } - setNiceExtent(min: number, max: number): void { - this._niceExtent = [min, max]; + // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. } } diff --git a/src/scale/Log.ts b/src/scale/Log.ts index bdd799056c..d7be770575 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -18,81 +18,95 @@ */ import * as zrUtil from 'zrender/src/core/util'; -import Scale, { ScaleGetTicksOpt } from './Scale'; -import * as numberUtil from '../util/number'; +import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; +import { + mathFloor, mathCeil, mathPow, mathLog, + round, quantity, getPrecision +} from '../util/number'; // Use some method of IntervalScale import IntervalScale from './Interval'; import { DimensionLoose, DimensionName, ParsedAxisBreakList, AxisBreakOption, - ScaleTick + ScaleTick, + NullUndefined } from '../util/types'; -import { getIntervalPrecision, logTransform } from './helper'; +import { ensureValidSplitNumber, fixNiceExtent, getIntervalPrecision, logTransform } from './helper'; import SeriesData from '../data/SeriesData'; import { getScaleBreakHelper } from './break'; -const fixRound = numberUtil.round; -const mathFloor = Math.floor; -const mathCeil = Math.ceil; -const mathPow = Math.pow; -const mathLog = Math.log; +const LINEAR_STUB_METHODS = [ + 'getExtent', 'getTicks', 'getInterval' + // Keep no setting method to mitigate vulnerability. +] as const; + +/** + * IMPL_MEMO: + * - The supper class (`IntervalScale`) and its member fields (such as `this._extent`, + * `this._interval`, `this._niceExtent`) provides linear tick arrangement (logarithm applied). + * - `_originalScale` (`IntervalScale`) is used to save some original info + * (before logarithm applied, such as raw extent). + */ class LogScale extends IntervalScale { static type = 'log'; readonly type = 'log'; - base = 10; + readonly base: number; private _originalScale = new IntervalScale(); - private _fixMin: boolean; - private _fixMax: boolean; + // `[fixMin, fixMax]` + private _fixMinMax: boolean[] = [false, false]; + + linearStub: Pick; + + constructor(logBase: number | NullUndefined, settings?: ScaleSettingDefault) { + super(settings); + this.base = zrUtil.retrieve2(logBase, 10); + this._initLinearStub(); + } + + private _initLinearStub(): void { + // TODO: Refactor -- This impl is error-prone. And the use of `prototype` should be removed. + const intervalScaleProto = IntervalScale.prototype; + const logScale = this; + const stub = logScale.linearStub = {} as LogScale['linearStub']; + zrUtil.each(LINEAR_STUB_METHODS, function (methodName) { + stub[methodName] = function () { + return (intervalScaleProto[methodName] as any).apply(logScale, arguments); + }; + }); + } /** * @param Whether expand the ticks to niced extent. */ getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { - opt = opt || {}; - const extent = this._extent.slice() as [number, number]; - const originalExtent = this._originalScale.getExtent(); - - const ticks = super.getTicks(opt); - const base = this.base; - const originalBreaks = this._originalScale._innerGetBreaks(); + const extent = this._extent; const scaleBreakHelper = getScaleBreakHelper(); - return zrUtil.map(ticks, function (tick) { - const val = tick.value; - let roundingCriterion = null; - - let powVal = mathPow(base, val); - - // Fix #4158 - if (val === extent[0] && this._fixMin) { - roundingCriterion = originalExtent[0]; - } - else if (val === extent[1] && this._fixMax) { - roundingCriterion = originalExtent[1]; - } - + return zrUtil.map(super.getTicks(opt || {}), function (tick) { let vBreak; + let brkRoundingCriterion; if (scaleBreakHelper) { const transformed = scaleBreakHelper.getTicksLogTransformBreak( tick, - base, - originalBreaks, + this.base, + this._originalScale._innerGetBreaks(), fixRoundingError ); vBreak = transformed.vBreak; - if (roundingCriterion == null) { - roundingCriterion = transformed.brkRoundingCriterion; - } + brkRoundingCriterion = transformed.brkRoundingCriterion; } - if (roundingCriterion != null) { - powVal = fixRoundingError(powVal, roundingCriterion); - } + const val = tick.value; + const powVal = this.powTick( + val, + val === extent[1] ? 1 : val === extent[0] ? 0 : null, + brkRoundingCriterion + ); return { value: powVal, @@ -106,26 +120,18 @@ class LogScale extends IntervalScale { } setExtent(start: number, end: number): void { + // [CAVEAT]: If modifying this logic, must sync to `_initLinearStub`. this._originalScale.setExtent(start, end); const loggedExtent = logTransform(this.base, [start, end]); super.setExtent(loggedExtent[0], loggedExtent[1]); } - /** - * @return {number} end - */ getExtent() { - const base = this.base; const extent = super.getExtent(); - extent[0] = mathPow(base, extent[0]); - extent[1] = mathPow(base, extent[1]); - - // Fix #4158 - const originalExtent = this._originalScale.getExtent(); - this._fixMin && (extent[0] = fixRoundingError(extent[0], originalExtent[0])); - this._fixMax && (extent[1] = fixRoundingError(extent[1], originalExtent[1])); - - return extent; + return [ + this.powTick(extent[0], 0, null), + this.powTick(extent[1], 1, null) + ] as [number, number]; } unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { @@ -134,23 +140,55 @@ class LogScale extends IntervalScale { this._innerUnionExtent(loggedOther); } + /** + * fixMin/Max and rounding error are addressed. + */ + powTick( + // `val` should be in the linear space. + val: number, + // `0`: `value` is `min`; + // `1`: `value` is `max`; + // `NullUndefined`: others. + extentIdx: 0 | 1 | NullUndefined, + fallbackRoundingCriterion: number | NullUndefined + ): number { + // NOTE: `Math.pow(10, integer)` has no rounding error. + // PENDING: other base? + let powVal = mathPow(this.base, val); + + // Fix #4158 + // NOTE: Even when `fixMin/Max` is `true`, `pow(base, this._extent[0]/[1])` may be still + // not equal to `this._originalScale.getExtent()[0]`/`[1]` in invalid extent case. + // So we always call `Math.pow`. + const roundingCriterion = this._fixMinMax[extentIdx] + ? this._originalScale.getExtent()[extentIdx] + : fallbackRoundingCriterion; + + if (roundingCriterion != null) { + powVal = fixRoundingError(powVal, roundingCriterion); + } + + return powVal; + } + /** * Update interval and extent of intervals for nice ticks - * @param approxTickNum default 10 Given approx tick number + * @param splitNumber default 10 Given approx tick number */ - calcNiceTicks(approxTickNum: number): void { - approxTickNum = approxTickNum || 10; + calcNiceTicks(splitNumber: number): void { + splitNumber = ensureValidSplitNumber(splitNumber, 10); const extent = this._extent.slice() as [number, number]; const span = this._getExtentSpanWithBreaks(); if (!isFinite(span) || span <= 0) { return; } - let interval = numberUtil.quantity(span); - const err = approxTickNum / span * interval; + let interval = quantity(span); + const err = splitNumber / span * interval; // Filter ticks to get closer to the desired count. if (err <= 0.5) { + // TODO: support other bases other than 10? interval *= 10; } @@ -159,14 +197,19 @@ class LogScale extends IntervalScale { interval *= 10; } + const intervalPrecision = getIntervalPrecision(interval); const niceExtent = [ - fixRound(mathCeil(extent[0] / interval) * interval), - fixRound(mathFloor(extent[1] / interval) * interval) + round(mathCeil(extent[0] / interval) * interval, intervalPrecision), + round(mathFloor(extent[1] / interval) * interval, intervalPrecision) ] as [number, number]; + fixNiceExtent(niceExtent, extent); + this._interval = interval; - this._intervalPrecision = getIntervalPrecision(interval); + this._intervalPrecision = intervalPrecision; this._niceExtent = niceExtent; + + // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. } calcNiceExtent(opt: { @@ -178,8 +221,7 @@ class LogScale extends IntervalScale { }): void { super.calcNiceExtent(opt); - this._fixMin = opt.fixMin; - this._fixMax = opt.fixMax; + this._fixMinMax = [!!opt.fixMin, !!opt.fixMax]; } contain(val: number): boolean { @@ -216,7 +258,7 @@ class LogScale extends IntervalScale { } function fixRoundingError(val: number, originalVal: number): number { - return fixRound(val, numberUtil.getPrecision(originalVal)); + return round(val, getPrecision(originalVal)); } diff --git a/src/scale/Scale.ts b/src/scale/Scale.ts index 1f1fbdecf2..801e2a2eb8 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -117,7 +117,7 @@ abstract class Scale /** * [CAVEAT]: It should not be overridden! */ - _innerUnionExtent(other: [number, number]): void { + _innerUnionExtent(other: number[]): void { const extent = this._extent; // Considered that number could be NaN and should not write into the extent. this._innerSetExtent( diff --git a/src/scale/helper.ts b/src/scale/helper.ts index c5143d5309..f89abca5d1 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -106,7 +106,7 @@ export function intervalScaleNiceTicks( */ export function increaseInterval(niceInterval: number) { const exponent = quantityExponent(niceInterval); - // No rounding error in Math.pow(10, xxx). + // No rounding error in Math.pow(10, integer). const exp10 = mathPow(10, exponent); // Fix IEEE 754 float rounding error let f = mathRound(niceInterval / exp10); @@ -203,13 +203,6 @@ export function logTransform(base: number, extent: number[], noClampNegative?: b ]; } -export function powTransform(base: number, extent: number[]): [number, number] { - return [ - mathPow(base, extent[0]), - mathPow(base, extent[1]) - ]; -} - /** * A valid extent is: * - No non-finite number. diff --git a/src/util/number.ts b/src/util/number.ts index 0743743516..552a9ec837 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -288,10 +288,13 @@ export function getPixelPrecision(dataExtent: [number, number], pixelExtent: [nu * "data" is linearly mapped to pixel according to the ratio determined by `dataSpan` and `pxSpan`. * The diff from the original "data" to the rounded "data" (with the result precision) should be * equal or less than `pxDiffAcceptable`, which is typically `1` pixel. - * And the result precision should be as small as possible. + * And the result precision should be as small as possible for a concise display. * - * [NOTICE]: using arbitrary parameters is not preferable -- a discernible misalign (e.g., over 1px) - * may occur, especially when `splitLine` displayed. + * [NOTICE]: using arbitrary parameters is NOT preferable - a discernible misalign (e.g., over 1px) + * may occur, especially when `splitLine` is displayed. + * + * PENDING: Only linear case is addressed for now; other mapping methods (like log) will not be + * covered until necessary. */ export function getAcceptableTickPrecision( // Typically, `Math.abs(dataExtent[1] - dataExtent[0])`. @@ -574,7 +577,7 @@ export function nice( // e.g., if `val` is `0`, // The result is `1`. const exponent = quantityExponent(val); - // No rounding error in Math.pow(10, xxx). + // No rounding error in Math.pow(10, integer). const exp10 = mathPow(10, exponent); const f = val / exp10; diff --git a/test/axis-align-edge-cases.html b/test/axis-align-edge-cases.html new file mode 100644 index 0000000000..d851f1bdd4 --- /dev/null +++ b/test/axis-align-edge-cases.html @@ -0,0 +1,746 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/axis-align-ticks-random.html b/test/axis-align-ticks-random.html index 42e0858dba..8b502e713d 100644 --- a/test/axis-align-ticks-random.html +++ b/test/axis-align-ticks-random.html @@ -83,6 +83,8 @@

Right axis should follow split numbers from left axis.

'echarts' ], function (echarts) { + const __EC_OPTIONS_FOR_DEBUG = window.__EC_OPTIONS_FOR_DEBUG = {}; + function makeOption(leftMin, leftMax, rightMin, rightMax, splitNumber) { @@ -145,11 +147,13 @@

Right axis should follow split numbers from left axis.

} function makeTestCharts(containerId, leftMin, leftMax, rightMin, rightMax) { + __EC_OPTIONS_FOR_DEBUG[containerId] = []; const container = document.querySelector(containerId); for (let i = 0; i < 15; i++) { const dom = document.createElement('div'); dom.className = 'chart'; const option = makeOption(leftMin, leftMax, rightMin, rightMax); + __EC_OPTIONS_FOR_DEBUG[containerId].push(option); container.appendChild(dom); const chart = echarts.init(dom); chart.setOption(option); diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index 6d72723021..a4e134f69b 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -8,6 +8,7 @@ "aria-pie": 2, "axes": 0, "axis": 1, + "axis-align-edge-cases": 5, "axis-align-ticks": 4, "axis-boundaryGap": 1, "axis-break": 7, diff --git a/test/runTest/actions/axis-align-edge-cases.json b/test/runTest/actions/axis-align-edge-cases.json new file mode 100644 index 0000000000..aea4cbc16f --- /dev/null +++ b/test/runTest/actions/axis-align-edge-cases.json @@ -0,0 +1 @@ +[{"name":"Action 1","ops":[{"type":"mousemove","time":286,"x":586,"y":156},{"type":"mousedown","time":434,"x":583,"y":154},{"type":"mousemove","time":492,"x":583,"y":154},{"type":"mouseup","time":517,"x":583,"y":154},{"time":518,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":735,"x":582,"y":157},{"type":"mousemove","time":935,"x":519,"y":321},{"type":"mousemove","time":1135,"x":474,"y":318},{"type":"mousemove","time":1343,"x":455,"y":298},{"type":"mousedown","time":1484,"x":455,"y":296},{"type":"mouseup","time":1567,"x":455,"y":296},{"time":1568,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1600,"x":455,"y":296},{"type":"mousemove","time":1986,"x":454,"y":296},{"type":"mousemove","time":2185,"x":369,"y":295},{"type":"mousedown","time":2250,"x":367,"y":295},{"type":"mouseup","time":2366,"x":367,"y":295},{"time":2367,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2392,"x":367,"y":295},{"type":"mousemove","time":2501,"x":368,"y":295},{"type":"mousemove","time":2701,"x":425,"y":292},{"type":"mousemove","time":2902,"x":460,"y":289},{"type":"mousedown","time":2983,"x":460,"y":289},{"type":"mouseup","time":3100,"x":460,"y":289},{"time":3101,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3128,"x":460,"y":289},{"type":"mousemove","time":3235,"x":460,"y":289},{"type":"mousemove","time":3436,"x":377,"y":291},{"type":"mousedown","time":3534,"x":366,"y":291},{"type":"mouseup","time":3633,"x":366,"y":291},{"time":3634,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3663,"x":366,"y":291},{"type":"mousemove","time":4302,"x":366,"y":290},{"type":"mousemove","time":4502,"x":373,"y":258},{"type":"mousemove","time":4702,"x":384,"y":194},{"type":"mousemove","time":4910,"x":391,"y":163},{"type":"mousemove","time":5129,"x":391,"y":163},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":5996,"target":"select"},{"time":5997,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6069,"x":387,"y":161},{"type":"mousemove","time":6278,"x":388,"y":160},{"type":"mousemove","time":6511,"x":388,"y":160},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":7528,"target":"select"},{"time":7529,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7619,"x":395,"y":152},{"type":"mousemove","time":7827,"x":395,"y":152},{"type":"mousemove","time":7852,"x":395,"y":152},{"type":"mousemove","time":8061,"x":394,"y":156},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":8994,"target":"select"},{"time":8995,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9068,"x":397,"y":160},{"type":"mousemove","time":9277,"x":397,"y":160},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":10345,"target":"select"},{"time":10346,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10419,"x":395,"y":164},{"type":"mousemove","time":10627,"x":402,"y":156},{"type":"mousemove","time":10893,"x":402,"y":156},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":11677,"target":"select"},{"time":11678,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11752,"x":400,"y":158},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":12865,"target":"select"},{"time":12866,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12952,"x":403,"y":166},{"type":"mousemove","time":13159,"x":405,"y":164},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"7","time":14195,"target":"select"},{"time":14196,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14269,"x":393,"y":162},{"type":"mousemove","time":14477,"x":393,"y":162},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"8","time":15327,"target":"select"},{"time":15328,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15419,"x":404,"y":158},{"type":"mousemove","time":15627,"x":405,"y":157},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"9","time":16461,"target":"select"},{"time":16462,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16536,"x":408,"y":154},{"type":"mousemove","time":16743,"x":408,"y":154},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"10","time":17580,"target":"select"},{"time":17581,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":17619,"x":519,"y":175},{"type":"mousemove","time":17819,"x":672,"y":187},{"type":"mousemove","time":18028,"x":644,"y":184},{"type":"mousemove","time":18276,"x":643,"y":184},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"2","time":19577,"target":"select"},{"time":19578,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":19619,"x":481,"y":196},{"type":"mousemove","time":19819,"x":387,"y":165},{"type":"mousemove","time":20026,"x":387,"y":164},{"type":"mousemove","time":20703,"x":386,"y":164},{"type":"mousemove","time":20909,"x":379,"y":168},{"type":"mousemove","time":21119,"x":337,"y":324},{"type":"mousemove","time":21319,"x":337,"y":320},{"type":"mousemove","time":21519,"x":333,"y":203},{"type":"mousemove","time":21719,"x":342,"y":163},{"type":"mousemove","time":21926,"x":343,"y":161},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":24011,"target":"select"},{"time":24012,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":24070,"x":322,"y":134},{"type":"mousemove","time":24278,"x":316,"y":243},{"type":"mousedown","time":25567,"x":337,"y":297},{"type":"mouseup","time":25650,"x":337,"y":297},{"time":25651,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":26176,"x":433,"y":293},{"type":"mouseup","time":26300,"x":433,"y":293},{"time":26301,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":27117,"x":377,"y":296},{"type":"mouseup","time":27250,"x":377,"y":296},{"time":27251,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":27601,"x":430,"y":296},{"type":"mouseup","time":27717,"x":430,"y":296},{"time":27718,"delay":400,"type":"screenshot-auto"},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":30012,"target":"select"},{"time":30013,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":30086,"x":381,"y":170},{"type":"mousemove","time":30294,"x":384,"y":165},{"type":"mousemove","time":30511,"x":384,"y":156},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":31279,"target":"select"},{"time":31280,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":31353,"x":398,"y":162},{"type":"mousemove","time":31560,"x":398,"y":155},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":32395,"target":"select"},{"time":32396,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":32486,"x":388,"y":161},{"type":"mousemove","time":32695,"x":388,"y":161},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":33529,"target":"select"},{"time":33530,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":33603,"x":399,"y":161},{"type":"mousemove","time":33811,"x":399,"y":161},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":34995,"target":"select"},{"time":34996,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":35053,"x":375,"y":183},{"type":"mousemove","time":35261,"x":391,"y":165},{"type":"mousemove","time":35478,"x":393,"y":163},{"type":"mousemove","time":35586,"x":393,"y":163},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":36579,"target":"select"},{"time":36580,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":36653,"x":389,"y":161},{"type":"mousemove","time":36862,"x":389,"y":161},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"7","time":37896,"target":"select"},{"time":37897,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":37953,"x":387,"y":162},{"type":"mousemove","time":38153,"x":392,"y":157},{"type":"mousemove","time":38361,"x":392,"y":156},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"8","time":39179,"target":"select"},{"time":39180,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":39253,"x":380,"y":165},{"type":"mousemove","time":39461,"x":384,"y":158},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"9","time":40432,"target":"select"},{"time":40433,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":40553,"x":381,"y":156},{"type":"mousemove","time":40760,"x":387,"y":149},{"type":"mousemove","time":40969,"x":381,"y":155},{"type":"mousemove","time":41170,"x":378,"y":158},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"10","time":42048,"target":"select"},{"time":42049,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":42103,"x":374,"y":177},{"type":"mousemove","time":42312,"x":374,"y":177},{"type":"mousemove","time":42529,"x":384,"y":164},{"type":"mousemove","time":42745,"x":386,"y":159},{"type":"mousemove","time":42960,"x":386,"y":159},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":45694,"target":"select"},{"time":45695,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":45820,"x":387,"y":2},{"type":"mousemove","time":46029,"x":397,"y":264},{"type":"mousemove","time":46319,"x":402,"y":261},{"type":"mousemove","time":46520,"x":496,"y":239},{"type":"mousemove","time":46720,"x":455,"y":278},{"type":"mousemove","time":46928,"x":445,"y":293},{"type":"mousedown","time":47002,"x":445,"y":293},{"type":"mouseup","time":47101,"x":445,"y":293},{"time":47102,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":47146,"x":445,"y":293},{"type":"mousemove","time":47336,"x":442,"y":293},{"type":"mousemove","time":47537,"x":381,"y":299},{"type":"mousedown","time":47685,"x":374,"y":298},{"type":"mousemove","time":47744,"x":374,"y":298},{"type":"mouseup","time":47851,"x":374,"y":298},{"time":47852,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":48835,"x":374,"y":298},{"type":"mouseup","time":48934,"x":374,"y":298},{"time":48935,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":49036,"x":378,"y":298},{"type":"mousemove","time":49245,"x":424,"y":298},{"type":"mousedown","time":49253,"x":424,"y":298},{"type":"mouseup","time":49334,"x":424,"y":298},{"time":49335,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":49462,"x":424,"y":298},{"type":"mousemove","time":49620,"x":424,"y":295},{"type":"mousemove","time":49820,"x":393,"y":164},{"type":"mousemove","time":51420,"x":638,"y":219},{"type":"mousemove","time":51620,"x":623,"y":184},{"type":"mousemove","time":51845,"x":623,"y":184},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"6","time":52829,"target":"select"},{"time":52830,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":52870,"x":565,"y":278},{"type":"mousemove","time":53070,"x":360,"y":196},{"type":"mousemove","time":53278,"x":358,"y":172},{"type":"mousemove","time":53495,"x":359,"y":170},{"type":"mousedown","time":53503,"x":359,"y":170},{"type":"mouseup","time":53653,"x":359,"y":170},{"time":53654,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":54054,"x":359,"y":168},{"type":"mousemove","time":54262,"x":361,"y":159},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":55463,"target":"select"},{"time":55464,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":55520,"x":352,"y":250},{"type":"mousemove","time":55720,"x":382,"y":281},{"type":"mousemove","time":55920,"x":417,"y":288},{"type":"mousemove","time":56120,"x":423,"y":289},{"type":"mousedown","time":56163,"x":423,"y":289},{"type":"mouseup","time":56251,"x":423,"y":290},{"time":56252,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":56321,"x":422,"y":290},{"type":"mousemove","time":56532,"x":382,"y":290},{"type":"mousemove","time":56746,"x":381,"y":290},{"type":"mousedown","time":57636,"x":381,"y":290},{"type":"mouseup","time":57768,"x":381,"y":290},{"time":57769,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":57920,"x":382,"y":290},{"type":"mousemove","time":58121,"x":442,"y":289},{"type":"mousedown","time":58245,"x":446,"y":289},{"type":"mousemove","time":58328,"x":446,"y":289},{"type":"mouseup","time":58352,"x":446,"y":289},{"time":58353,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":58486,"x":445,"y":289},{"type":"mousemove","time":58687,"x":387,"y":291},{"type":"mousedown","time":58897,"x":369,"y":293},{"type":"mousemove","time":58916,"x":369,"y":293},{"type":"mouseup","time":59002,"x":369,"y":293},{"time":59003,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":60038,"x":369,"y":293},{"type":"mousemove","time":60238,"x":406,"y":252},{"type":"mousemove","time":60438,"x":513,"y":185},{"type":"mousemove","time":60638,"x":413,"y":172},{"type":"mousedown","time":60785,"x":408,"y":172},{"type":"mousemove","time":60846,"x":408,"y":172},{"type":"mouseup","time":60868,"x":408,"y":172},{"time":60869,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":61038,"x":408,"y":171},{"type":"mousemove","time":61246,"x":408,"y":167},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":62280,"target":"select"},{"time":62281,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":62354,"x":399,"y":162},{"type":"mousemove","time":62564,"x":399,"y":162},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":63664,"target":"select"},{"time":63665,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":63754,"x":398,"y":161},{"type":"mousemove","time":63963,"x":399,"y":159},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":64830,"target":"select"},{"time":64831,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":64920,"x":397,"y":164},{"type":"mousemove","time":65131,"x":397,"y":164},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":65997,"target":"select"},{"time":65998,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":66087,"x":400,"y":163},{"type":"mousemove","time":66296,"x":400,"y":162},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":67180,"target":"select"},{"time":67181,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":67271,"x":391,"y":163},{"type":"mousemove","time":67479,"x":393,"y":161},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":68563,"target":"select"},{"time":68564,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":68654,"x":398,"y":163},{"type":"mousemove","time":68864,"x":398,"y":164},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"7","time":69713,"target":"select"},{"time":69714,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":69804,"x":398,"y":161},{"type":"mousemove","time":70013,"x":398,"y":160},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"8","time":70863,"target":"select"},{"time":70864,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":70937,"x":395,"y":159},{"type":"mousemove","time":71137,"x":395,"y":159},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"9","time":71929,"target":"select"},{"time":71930,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":72003,"x":390,"y":165},{"type":"mousemove","time":72214,"x":390,"y":165},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"10","time":73014,"target":"select"},{"time":73015,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":73072,"x":389,"y":165},{"type":"mousemove","time":73281,"x":391,"y":163},{"type":"mousemove","time":73498,"x":391,"y":162},{"type":"valuechange","selector":"#main_cartesian_0_integerData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":75230,"target":"select"},{"time":75231,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":75288,"x":440,"y":190},{"type":"mousemove","time":75497,"x":551,"y":190},{"type":"mousemove","time":75704,"x":563,"y":234},{"type":"mousemove","time":75904,"x":514,"y":268},{"type":"mousemove","time":76104,"x":425,"y":305},{"type":"mousemove","time":76304,"x":428,"y":294},{"type":"mousedown","time":76436,"x":432,"y":287},{"type":"mousemove","time":76514,"x":432,"y":287},{"type":"mouseup","time":76536,"x":432,"y":287},{"time":76537,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":76887,"x":429,"y":287},{"type":"mousemove","time":77087,"x":370,"y":290},{"type":"mousedown","time":77180,"x":361,"y":290},{"type":"mouseup","time":77286,"x":361,"y":290},{"time":77287,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":77316,"x":361,"y":290},{"type":"mousemove","time":77921,"x":361,"y":290},{"type":"mousemove","time":78121,"x":373,"y":291},{"type":"mousemove","time":78329,"x":384,"y":291},{"type":"mousemove","time":78538,"x":418,"y":290},{"type":"mousedown","time":78585,"x":419,"y":290},{"type":"mouseup","time":78685,"x":419,"y":290},{"time":78686,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":78746,"x":419,"y":290},{"type":"mousemove","time":78954,"x":361,"y":292},{"type":"mousedown","time":78986,"x":361,"y":292},{"type":"mouseup","time":79102,"x":361,"y":292},{"time":79103,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":79164,"x":361,"y":292},{"type":"mousemove","time":79688,"x":361,"y":292},{"type":"mousemove","time":79895,"x":385,"y":292},{"type":"mousemove","time":80105,"x":450,"y":281},{"type":"mousemove","time":80305,"x":598,"y":258},{"type":"mousedown","time":80446,"x":623,"y":250},{"type":"mousemove","time":80513,"x":623,"y":250},{"type":"mouseup","time":80552,"x":623,"y":250},{"time":80553,"delay":400,"type":"screenshot-auto"}],"scrollY":0,"scrollX":0,"timestamp":1767883898806},{"name":"Action 2","ops":[{"type":"mousemove","time":643,"x":722,"y":169},{"type":"mousemove","time":851,"x":571,"y":172},{"type":"mousemove","time":1067,"x":431,"y":172},{"type":"mousemove","time":1142,"x":428,"y":172},{"type":"mousemove","time":1342,"x":359,"y":135},{"type":"mousemove","time":1550,"x":360,"y":126},{"type":"mousemove","time":1759,"x":404,"y":149},{"type":"mousemove","time":1968,"x":369,"y":229},{"type":"mousemove","time":2185,"x":381,"y":242},{"type":"mousemove","time":2392,"x":413,"y":240},{"type":"mousedown","time":2573,"x":426,"y":239},{"type":"mousemove","time":2603,"x":426,"y":239},{"type":"mouseup","time":2640,"x":426,"y":239},{"time":2641,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2859,"x":424,"y":239},{"type":"mousemove","time":3059,"x":383,"y":241},{"type":"mousemove","time":3268,"x":365,"y":241},{"type":"mousedown","time":3440,"x":365,"y":241},{"type":"mousemove","time":3483,"x":365,"y":241},{"type":"mouseup","time":3558,"x":365,"y":241},{"time":3559,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3826,"x":366,"y":241},{"type":"mousemove","time":4026,"x":424,"y":243},{"type":"mousedown","time":4090,"x":428,"y":243},{"type":"mousemove","time":4234,"x":428,"y":243},{"type":"mouseup","time":4243,"x":428,"y":243},{"time":4244,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4359,"x":426,"y":243},{"type":"mousemove","time":4559,"x":378,"y":243},{"type":"mousedown","time":4723,"x":369,"y":242},{"type":"mousemove","time":4768,"x":369,"y":242},{"type":"mouseup","time":4940,"x":369,"y":242},{"time":4941,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6443,"x":369,"y":242},{"type":"mousemove","time":6643,"x":374,"y":143},{"type":"mousemove","time":6852,"x":379,"y":115},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":7919,"target":"select"},{"time":7920,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7993,"x":389,"y":113},{"type":"mousemove","time":8203,"x":389,"y":112},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":9519,"target":"select"},{"time":9520,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9575,"x":425,"y":192},{"type":"mousemove","time":9776,"x":449,"y":225},{"type":"mousemove","time":9976,"x":445,"y":246},{"type":"mousedown","time":10290,"x":445,"y":246},{"type":"mouseup","time":10441,"x":445,"y":246},{"time":10442,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10543,"x":444,"y":246},{"type":"mousemove","time":10743,"x":384,"y":246},{"type":"mousemove","time":10952,"x":365,"y":250},{"type":"mousedown","time":10974,"x":365,"y":250},{"type":"mouseup","time":11125,"x":365,"y":250},{"time":11126,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11170,"x":365,"y":250},{"type":"mousemove","time":11426,"x":367,"y":250},{"type":"mousemove","time":11635,"x":383,"y":250},{"type":"mousedown","time":11791,"x":367,"y":244},{"type":"mousemove","time":11868,"x":367,"y":244},{"type":"mouseup","time":11923,"x":367,"y":244},{"time":11924,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12076,"x":398,"y":244},{"type":"mousemove","time":12276,"x":421,"y":244},{"type":"mousedown","time":12296,"x":421,"y":244},{"type":"mouseup","time":12392,"x":421,"y":244},{"time":12393,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13292,"x":418,"y":244},{"type":"mousedown","time":13474,"x":372,"y":240},{"type":"mousemove","time":13505,"x":372,"y":240},{"type":"mouseup","time":13574,"x":372,"y":240},{"time":13575,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13709,"x":399,"y":240},{"type":"mousedown","time":13907,"x":425,"y":240},{"type":"mousemove","time":13938,"x":425,"y":240},{"type":"mouseup","time":14040,"x":425,"y":240},{"time":14041,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14409,"x":424,"y":240},{"type":"mousemove","time":14609,"x":379,"y":241},{"type":"mousedown","time":14757,"x":378,"y":241},{"type":"mousemove","time":14820,"x":378,"y":241},{"type":"mouseup","time":14874,"x":378,"y":241},{"time":14875,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15026,"x":421,"y":241},{"type":"mousedown","time":15141,"x":433,"y":241},{"type":"mouseup","time":15237,"x":433,"y":241},{"time":15238,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15252,"x":433,"y":242},{"type":"mousemove","time":15461,"x":433,"y":238},{"type":"mousemove","time":15672,"x":395,"y":140},{"type":"mousemove","time":15875,"x":392,"y":120},{"type":"mousemove","time":16076,"x":391,"y":113},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":17070,"target":"select"},{"time":17071,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":17160,"x":414,"y":116},{"type":"mousemove","time":17359,"x":403,"y":112},{"type":"mousemove","time":17559,"x":401,"y":112},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":18302,"target":"select"},{"time":18303,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":18360,"x":401,"y":107},{"type":"mousemove","time":18570,"x":401,"y":107},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":19537,"target":"select"},{"time":19538,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":19610,"x":403,"y":115},{"type":"mousemove","time":19820,"x":403,"y":114},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":20804,"target":"select"},{"time":20805,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":20893,"x":399,"y":118},{"type":"mousemove","time":21103,"x":399,"y":117},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"7","time":22103,"target":"select"},{"time":22104,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":22160,"x":393,"y":119},{"type":"mousemove","time":22370,"x":393,"y":119},{"type":"mousemove","time":22586,"x":394,"y":118},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"8","time":23419,"target":"select"},{"time":23420,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":23476,"x":393,"y":114},{"type":"mousemove","time":23687,"x":393,"y":114},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"9","time":24587,"target":"select"},{"time":24588,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":24676,"x":396,"y":118},{"type":"mousemove","time":24886,"x":398,"y":111},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"10","time":25720,"target":"select"},{"time":25721,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":25752,"x":512,"y":129},{"type":"mousemove","time":25954,"x":635,"y":138},{"type":"mousemove","time":26171,"x":632,"y":134},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"2","time":27453,"target":"select"},{"time":27454,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":27510,"x":450,"y":125},{"type":"mousemove","time":27710,"x":339,"y":108},{"type":"mousemove","time":27920,"x":339,"y":106},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":29486,"target":"select"},{"time":29487,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":29614,"x":390,"y":108},{"type":"mousemove","time":29820,"x":389,"y":121},{"type":"mousemove","time":30052,"x":389,"y":115},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":31287,"target":"select"},{"time":31288,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":31313,"x":365,"y":140},{"type":"mousemove","time":31520,"x":378,"y":132},{"type":"mousemove","time":31926,"x":378,"y":132},{"type":"mousemove","time":32126,"x":378,"y":109},{"type":"mousemove","time":32336,"x":378,"y":108},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":33319,"target":"select"},{"time":33320,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":33393,"x":377,"y":121},{"type":"mousemove","time":33593,"x":382,"y":114},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":34521,"target":"select"},{"time":34522,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":34593,"x":388,"y":114},{"type":"mousemove","time":34804,"x":391,"y":108},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":35769,"target":"select"},{"time":35770,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":35844,"x":384,"y":120},{"type":"mousemove","time":36054,"x":387,"y":111},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":36938,"target":"select"},{"time":36939,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":36993,"x":380,"y":125},{"type":"mousemove","time":37204,"x":392,"y":114},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":38238,"target":"select"},{"time":38239,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":38326,"x":391,"y":120},{"type":"mousemove","time":38539,"x":392,"y":118},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"7","time":39438,"target":"select"},{"time":39439,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":39509,"x":395,"y":111},{"type":"mousemove","time":39721,"x":394,"y":108},{"type":"mousemove","time":39953,"x":394,"y":108},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"8","time":40722,"target":"select"},{"time":40723,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":40810,"x":409,"y":104},{"type":"mousemove","time":41010,"x":408,"y":103},{"type":"mousemove","time":41220,"x":408,"y":103},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"9","time":41971,"target":"select"},{"time":41972,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":42044,"x":399,"y":104},{"type":"mousemove","time":42253,"x":399,"y":104},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"10","time":43204,"target":"select"},{"time":43205,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":43235,"x":543,"y":130},{"type":"mousemove","time":43439,"x":666,"y":137},{"type":"mousemove","time":43653,"x":635,"y":133},{"type":"mousemove","time":43869,"x":635,"y":133},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"6","time":45804,"target":"select"},{"time":45805,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":45836,"x":571,"y":210},{"type":"mousemove","time":46039,"x":372,"y":130},{"type":"mousemove","time":46243,"x":368,"y":107},{"type":"mousemove","time":46443,"x":368,"y":106},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":48154,"target":"select"},{"time":48155,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":48212,"x":383,"y":145},{"type":"mousemove","time":48422,"x":398,"y":119},{"type":"mousemove","time":48639,"x":395,"y":104},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":49553,"target":"select"},{"time":49554,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":49583,"x":373,"y":133},{"type":"mousemove","time":49789,"x":388,"y":126},{"type":"mousemove","time":50003,"x":402,"y":109},{"type":"mousemove","time":50237,"x":402,"y":109},{"type":"mousemove","time":50644,"x":402,"y":109},{"type":"mousemove","time":51061,"x":402,"y":109},{"type":"mousemove","time":51270,"x":401,"y":108},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":52370,"target":"select"},{"time":52371,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":52427,"x":414,"y":210},{"type":"mousemove","time":52627,"x":430,"y":245},{"type":"mousedown","time":52759,"x":427,"y":242},{"type":"mousemove","time":52839,"x":427,"y":242},{"type":"mouseup","time":52876,"x":427,"y":242},{"time":52877,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":52946,"x":426,"y":242},{"type":"mousemove","time":53157,"x":382,"y":242},{"type":"mousedown","time":53239,"x":377,"y":242},{"type":"mousemove","time":53372,"x":377,"y":242},{"type":"mouseup","time":53383,"x":377,"y":242},{"time":53384,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":53560,"x":379,"y":242},{"type":"mousemove","time":53760,"x":438,"y":242},{"type":"mousemove","time":53970,"x":441,"y":242},{"type":"mousedown","time":54293,"x":441,"y":242},{"type":"mouseup","time":54403,"x":441,"y":242},{"time":54404,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":54461,"x":439,"y":242},{"type":"mousemove","time":54661,"x":368,"y":242},{"type":"mousedown","time":54792,"x":367,"y":242},{"type":"mousemove","time":54872,"x":367,"y":242},{"type":"mouseup","time":54910,"x":367,"y":242},{"time":54911,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":55077,"x":367,"y":241},{"type":"mousemove","time":55277,"x":387,"y":150},{"type":"mousemove","time":55488,"x":390,"y":123},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":56837,"target":"select"},{"time":56838,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":56911,"x":409,"y":123},{"type":"mousemove","time":57111,"x":397,"y":105},{"type":"mousemove","time":57323,"x":397,"y":105},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":58056,"target":"select"},{"time":58057,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":58114,"x":392,"y":178},{"type":"mousemove","time":58323,"x":371,"y":223},{"type":"mousemove","time":58527,"x":357,"y":235},{"type":"mousemove","time":58729,"x":357,"y":239},{"type":"mousedown","time":58810,"x":357,"y":241},{"type":"mouseup","time":58910,"x":357,"y":241},{"time":58911,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":58949,"x":357,"y":241},{"type":"mousemove","time":59161,"x":455,"y":241},{"type":"mousedown","time":59185,"x":455,"y":241},{"type":"mouseup","time":59311,"x":455,"y":241},{"time":59312,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":59494,"x":450,"y":241},{"type":"mousemove","time":59694,"x":380,"y":241},{"type":"mousedown","time":59839,"x":379,"y":240},{"type":"mousemove","time":59905,"x":379,"y":240},{"type":"mouseup","time":59959,"x":379,"y":240},{"time":59960,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":60043,"x":381,"y":240},{"type":"mousemove","time":60244,"x":436,"y":240},{"type":"mousedown","time":60299,"x":436,"y":240},{"type":"mouseup","time":60408,"x":436,"y":240},{"time":60409,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":60462,"x":436,"y":240},{"type":"mousemove","time":60577,"x":436,"y":240},{"type":"mousemove","time":60777,"x":421,"y":190},{"type":"mousemove","time":60989,"x":405,"y":161},{"type":"mousemove","time":61144,"x":404,"y":160},{"type":"mousemove","time":61345,"x":385,"y":131},{"type":"mousemove","time":61556,"x":380,"y":114},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":62555,"target":"select"},{"time":62556,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":62611,"x":387,"y":122},{"type":"mousemove","time":62824,"x":393,"y":115},{"type":"mousemove","time":63039,"x":395,"y":113},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":63856,"target":"select"},{"time":63857,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":63913,"x":406,"y":110},{"type":"mousemove","time":64123,"x":406,"y":110},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":64956,"target":"select"},{"time":64957,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":65002,"x":410,"y":97},{"type":"mousemove","time":65207,"x":407,"y":102},{"type":"mousemove","time":65423,"x":406,"y":103},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":66222,"target":"select"},{"time":66223,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":66280,"x":416,"y":112},{"type":"mousemove","time":66489,"x":409,"y":109},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"7","time":67365,"target":"select"},{"time":67366,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":67415,"x":407,"y":111},{"type":"mousemove","time":67623,"x":407,"y":111},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"8","time":68522,"target":"select"},{"time":68523,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":68594,"x":410,"y":106},{"type":"mousemove","time":68806,"x":409,"y":106},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"9","time":69673,"target":"select"},{"time":69674,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":69750,"x":397,"y":107},{"type":"mousemove","time":69956,"x":397,"y":107},{"type":"valuechange","selector":"#main_cartesian_0_floatData>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"10","time":70789,"target":"select"},{"time":70790,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":70823,"x":491,"y":129},{"type":"mousemove","time":71025,"x":727,"y":166},{"type":"mousedown","time":71062,"x":727,"y":166},{"type":"mouseup","time":71176,"x":727,"y":166},{"time":71177,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":71275,"x":727,"y":166},{"type":"mousedown","time":71287,"x":727,"y":166},{"type":"mouseup","time":71409,"x":727,"y":166},{"time":71410,"delay":400,"type":"screenshot-auto"}],"scrollY":627,"scrollX":0,"timestamp":1767883984685},{"name":"Action 3","ops":[{"type":"mousemove","time":490,"x":491,"y":88},{"type":"mousemove","time":690,"x":462,"y":107},{"type":"mousemove","time":890,"x":440,"y":140},{"type":"mousemove","time":1102,"x":440,"y":148},{"type":"mousedown","time":1288,"x":440,"y":148},{"type":"mouseup","time":1422,"x":440,"y":148},{"time":1423,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1557,"x":439,"y":148},{"type":"mousemove","time":1758,"x":374,"y":150},{"type":"mousedown","time":1905,"x":361,"y":150},{"type":"mousemove","time":1969,"x":361,"y":150},{"type":"mouseup","time":2038,"x":361,"y":150},{"time":2039,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2190,"x":362,"y":150},{"type":"mousemove","time":2390,"x":418,"y":150},{"type":"mousemove","time":2602,"x":429,"y":149},{"type":"mousedown","time":2671,"x":429,"y":149},{"type":"mouseup","time":2821,"x":429,"y":149},{"time":2822,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2907,"x":428,"y":149},{"type":"mousemove","time":3107,"x":373,"y":149},{"type":"mousedown","time":3209,"x":366,"y":148},{"type":"mousemove","time":3319,"x":366,"y":148},{"type":"mouseup","time":3330,"x":366,"y":148},{"time":3331,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3690,"x":367,"y":148},{"type":"mousemove","time":3900,"x":376,"y":149},{"type":"mousedown","time":3912,"x":376,"y":149},{"type":"mouseup","time":4021,"x":376,"y":149},{"time":4022,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4073,"x":378,"y":149},{"type":"mousemove","time":4285,"x":441,"y":149},{"type":"mousedown","time":4309,"x":441,"y":149},{"type":"mouseup","time":4438,"x":441,"y":149},{"time":4439,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4490,"x":439,"y":149},{"type":"mousemove","time":4701,"x":372,"y":149},{"type":"mousedown","time":4839,"x":369,"y":149},{"type":"mouseup","time":4938,"x":369,"y":149},{"time":4939,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4977,"x":369,"y":149},{"type":"mousemove","time":5190,"x":446,"y":149},{"type":"mousedown","time":5219,"x":446,"y":149},{"type":"mouseup","time":5355,"x":446,"y":149},{"time":5356,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5415,"x":447,"y":149},{"type":"mousemove","time":5622,"x":628,"y":147},{"type":"mousedown","time":5706,"x":630,"y":146},{"type":"mouseup","time":5806,"x":630,"y":146},{"time":5807,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5844,"x":630,"y":146},{"type":"mousemove","time":6075,"x":632,"y":149},{"type":"mousemove","time":6275,"x":678,"y":199},{"type":"mousemove","time":6475,"x":678,"y":185},{"type":"mousemove","time":6689,"x":674,"y":184},{"type":"mousemove","time":6926,"x":673,"y":175},{"type":"mousemove","time":7128,"x":626,"y":236},{"type":"mousemove","time":7339,"x":599,"y":295},{"type":"mousemove","time":7557,"x":591,"y":309},{"type":"mousemove","time":7757,"x":584,"y":308},{"type":"mousemove","time":7970,"x":621,"y":172},{"type":"mousedown","time":8056,"x":621,"y":170},{"type":"mouseup","time":8157,"x":621,"y":170},{"time":8158,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8206,"x":621,"y":170}],"scrollY":1118,"scrollX":0,"timestamp":1767884081723},{"name":"Action 4","ops":[{"type":"mousemove","time":721,"x":719,"y":187},{"type":"mousemove","time":921,"x":716,"y":185},{"type":"mousemove","time":1132,"x":709,"y":180},{"type":"mousemove","time":1338,"x":751,"y":228},{"type":"mousemove","time":1539,"x":768,"y":240},{"type":"mousemove","time":1748,"x":770,"y":240},{"type":"mousemove","time":1966,"x":770,"y":239},{"type":"mousedown","time":2119,"x":770,"y":239},{"type":"mousemove","time":2131,"x":770,"y":239},{"type":"mousemove","time":2335,"x":772,"y":279},{"type":"mousemove","time":2538,"x":774,"y":297},{"type":"mousemove","time":2738,"x":775,"y":321},{"type":"mousemove","time":2950,"x":774,"y":327},{"type":"mousemove","time":3238,"x":774,"y":327},{"type":"mousemove","time":3439,"x":775,"y":318},{"type":"mousemove","time":3651,"x":775,"y":317},{"type":"mousemove","time":3705,"x":775,"y":317},{"type":"mousemove","time":3918,"x":775,"y":303},{"type":"mousemove","time":4128,"x":775,"y":284},{"type":"mousemove","time":4335,"x":773,"y":270},{"type":"mousemove","time":4538,"x":771,"y":258},{"type":"mousemove","time":4750,"x":770,"y":249},{"type":"mousemove","time":4955,"x":770,"y":244},{"type":"mousemove","time":5167,"x":771,"y":240},{"type":"mousemove","time":5434,"x":771,"y":238},{"type":"mousemove","time":5505,"x":771,"y":237},{"type":"mousemove","time":5717,"x":772,"y":237},{"type":"mousemove","time":6104,"x":772,"y":237},{"type":"mousemove","time":6304,"x":772,"y":245},{"type":"mousemove","time":6507,"x":772,"y":264},{"type":"mousemove","time":6720,"x":772,"y":278},{"type":"mousemove","time":6838,"x":773,"y":280},{"type":"mousemove","time":7040,"x":773,"y":326},{"type":"mousemove","time":7252,"x":774,"y":367},{"type":"mousemove","time":7455,"x":773,"y":390},{"type":"mousemove","time":7655,"x":773,"y":391},{"type":"mousemove","time":7869,"x":772,"y":314},{"type":"mousemove","time":8085,"x":772,"y":298},{"type":"mouseup","time":8252,"x":772,"y":298},{"time":8253,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8265,"x":778,"y":305},{"type":"mousemove","time":8473,"x":781,"y":327},{"type":"mousemove","time":8684,"x":781,"y":327},{"type":"mousedown","time":8855,"x":781,"y":327},{"type":"mousemove","time":8875,"x":781,"y":326},{"type":"mousemove","time":9083,"x":781,"y":311},{"type":"mousemove","time":9285,"x":781,"y":304},{"type":"mousemove","time":9490,"x":782,"y":289},{"type":"mousemove","time":9709,"x":782,"y":277},{"type":"mousemove","time":9924,"x":782,"y":251},{"type":"mousemove","time":10134,"x":782,"y":249},{"type":"mousemove","time":10434,"x":782,"y":250},{"type":"mouseup","time":10487,"x":782,"y":250},{"time":10488,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10500,"x":782,"y":257},{"type":"mousemove","time":10701,"x":778,"y":356},{"type":"mousemove","time":10905,"x":771,"y":406},{"type":"mousemove","time":11118,"x":770,"y":410},{"type":"mousemove","time":11322,"x":771,"y":407},{"type":"mousemove","time":11536,"x":771,"y":406},{"type":"mousedown","time":11720,"x":771,"y":406},{"type":"mousemove","time":11733,"x":771,"y":406},{"type":"mousemove","time":11954,"x":771,"y":433},{"type":"mousemove","time":12157,"x":771,"y":440},{"type":"mousemove","time":12373,"x":771,"y":444},{"type":"mousemove","time":12607,"x":771,"y":456},{"type":"mousemove","time":12840,"x":771,"y":461},{"type":"mousemove","time":12923,"x":771,"y":461},{"type":"mousemove","time":13124,"x":772,"y":446},{"type":"mousemove","time":13338,"x":773,"y":432},{"type":"mousemove","time":13538,"x":772,"y":410},{"type":"mousemove","time":13741,"x":772,"y":377},{"type":"mousemove","time":13943,"x":775,"y":395},{"type":"mousemove","time":14156,"x":774,"y":474},{"type":"mousemove","time":14369,"x":773,"y":488},{"type":"mousemove","time":14584,"x":773,"y":488},{"type":"mouseup","time":14721,"x":773,"y":488},{"time":14722,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14734,"x":771,"y":472},{"type":"mousemove","time":14949,"x":700,"y":219},{"type":"mousemove","time":15189,"x":694,"y":214},{"type":"mousemove","time":15401,"x":633,"y":158},{"type":"mousedown","time":15556,"x":632,"y":138},{"type":"mousemove","time":15619,"x":632,"y":138},{"type":"mouseup","time":15657,"x":632,"y":138},{"time":15658,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15900,"x":632,"y":157},{"type":"mousemove","time":16184,"x":634,"y":153},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"1","time":16980,"target":"select"},{"time":16981,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":17038,"x":637,"y":155},{"type":"mousemove","time":17238,"x":637,"y":151},{"type":"mousemove","time":17453,"x":637,"y":147},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"2","time":18492,"target":"select"},{"time":18493,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":18520,"x":636,"y":166},{"type":"mousemove","time":18775,"x":638,"y":167},{"type":"mousemove","time":18976,"x":753,"y":233},{"type":"mousemove","time":19185,"x":759,"y":237},{"type":"mousemove","time":19401,"x":761,"y":235},{"type":"mousedown","time":19604,"x":761,"y":235},{"type":"mousemove","time":19617,"x":761,"y":235},{"type":"mousemove","time":19819,"x":766,"y":225},{"type":"mousemove","time":20022,"x":763,"y":240},{"type":"mousemove","time":20234,"x":763,"y":241},{"type":"mouseup","time":20357,"x":763,"y":241},{"time":20358,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":20371,"x":749,"y":221},{"type":"mousemove","time":20585,"x":652,"y":147},{"type":"mousemove","time":20867,"x":650,"y":147},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"1","time":21744,"target":"select"},{"time":21745,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":21806,"x":653,"y":139},{"type":"mousemove","time":22009,"x":647,"y":149},{"type":"mousemove","time":22219,"x":649,"y":148},{"type":"mousemove","time":22432,"x":782,"y":280},{"type":"mousemove","time":22639,"x":776,"y":246},{"type":"mousemove","time":22852,"x":772,"y":239},{"type":"mousemove","time":23069,"x":772,"y":238},{"type":"mousedown","time":23270,"x":772,"y":238},{"type":"mousemove","time":23283,"x":772,"y":238},{"type":"mousemove","time":23499,"x":771,"y":244},{"type":"mousemove","time":23720,"x":771,"y":236},{"type":"mousemove","time":23934,"x":771,"y":235},{"type":"mousemove","time":23976,"x":771,"y":235},{"type":"mousemove","time":24186,"x":770,"y":238},{"type":"mousemove","time":24401,"x":770,"y":239},{"type":"mouseup","time":24415,"x":770,"y":239},{"time":24416,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":24428,"x":736,"y":219},{"type":"mousemove","time":24637,"x":623,"y":151},{"type":"mousemove","time":24843,"x":640,"y":148},{"type":"mousemove","time":25051,"x":640,"y":148},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"0","time":25876,"target":"select"},{"time":25877,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":25903,"x":669,"y":200},{"type":"mousemove","time":26104,"x":728,"y":271},{"type":"mousemove","time":26405,"x":730,"y":271},{"type":"mousemove","time":26605,"x":772,"y":342},{"type":"mousemove","time":26805,"x":773,"y":347},{"type":"mousemove","time":27051,"x":773,"y":345},{"type":"mousemove","time":27455,"x":771,"y":344},{"type":"mousemove","time":27655,"x":439,"y":165},{"type":"mousemove","time":27862,"x":732,"y":269},{"type":"mousemove","time":28069,"x":753,"y":280},{"type":"mousemove","time":28269,"x":766,"y":280},{"type":"mousedown","time":28488,"x":766,"y":280},{"type":"mousemove","time":28501,"x":766,"y":280},{"type":"mousemove","time":28703,"x":759,"y":331},{"type":"mousemove","time":28919,"x":758,"y":335},{"type":"mouseup","time":29069,"x":758,"y":335},{"time":29070,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":29088,"x":572,"y":234},{"type":"mousemove","time":29291,"x":387,"y":198},{"type":"mousemove","time":29457,"x":393,"y":198},{"type":"mousemove","time":29663,"x":741,"y":289},{"type":"mousemove","time":29906,"x":741,"y":289},{"type":"mousemove","time":30106,"x":776,"y":271},{"type":"mousemove","time":30319,"x":775,"y":273},{"type":"mousemove","time":30522,"x":773,"y":276},{"type":"mousemove","time":30736,"x":772,"y":278},{"type":"mousedown","time":30871,"x":772,"y":278},{"type":"mousemove","time":30884,"x":772,"y":278},{"type":"mousemove","time":31096,"x":765,"y":198},{"type":"mousemove","time":31304,"x":764,"y":193},{"type":"mouseup","time":31371,"x":764,"y":193},{"time":31372,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":31514,"x":778,"y":403},{"type":"mousemove","time":31723,"x":779,"y":382},{"type":"mousemove","time":31923,"x":776,"y":350},{"type":"mousemove","time":32123,"x":774,"y":336},{"type":"mousemove","time":32338,"x":773,"y":333},{"type":"mousedown","time":32573,"x":773,"y":333},{"type":"mousemove","time":32586,"x":773,"y":333},{"type":"mousemove","time":32789,"x":751,"y":496},{"type":"mousemove","time":33030,"x":751,"y":497},{"type":"mouseup","time":33255,"x":751,"y":497},{"time":33256,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":33269,"x":745,"y":473},{"type":"mousemove","time":33495,"x":686,"y":300},{"type":"mousemove","time":33789,"x":683,"y":299},{"type":"mousemove","time":33996,"x":522,"y":190},{"type":"mousemove","time":34207,"x":522,"y":155},{"type":"mousemove","time":34419,"x":520,"y":150},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"1","time":35380,"target":"select"},{"time":35381,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":35394,"x":500,"y":175},{"type":"mousemove","time":35604,"x":500,"y":175},{"type":"mousemove","time":35822,"x":643,"y":216},{"type":"mousemove","time":36024,"x":733,"y":242},{"type":"mousemove","time":36227,"x":767,"y":244},{"type":"mousemove","time":36455,"x":769,"y":235},{"type":"mousedown","time":36855,"x":769,"y":235},{"type":"mousemove","time":36868,"x":769,"y":235},{"type":"mousemove","time":37085,"x":767,"y":268},{"type":"mousemove","time":37300,"x":768,"y":286},{"type":"mousemove","time":37506,"x":768,"y":310},{"type":"mousemove","time":37720,"x":768,"y":312},{"type":"mousemove","time":37960,"x":769,"y":311},{"type":"mousemove","time":38174,"x":769,"y":305},{"type":"mousemove","time":38386,"x":769,"y":297},{"type":"mousemove","time":38605,"x":770,"y":284},{"type":"mousemove","time":38807,"x":770,"y":274},{"type":"mousemove","time":39007,"x":770,"y":266},{"type":"mousemove","time":39223,"x":770,"y":259},{"type":"mousemove","time":39374,"x":770,"y":259},{"type":"mousemove","time":39584,"x":770,"y":259},{"type":"mouseup","time":39596,"x":770,"y":259},{"time":39597,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":39610,"x":770,"y":272},{"type":"mousemove","time":39821,"x":771,"y":383},{"type":"mousemove","time":40023,"x":779,"y":393},{"type":"mousemove","time":40227,"x":782,"y":409},{"type":"mousemove","time":40436,"x":779,"y":453},{"type":"mousemove","time":40639,"x":769,"y":461},{"type":"mousemove","time":40857,"x":769,"y":462},{"type":"mousedown","time":41171,"x":769,"y":462},{"type":"mousemove","time":41185,"x":769,"y":462},{"type":"mousemove","time":41399,"x":769,"y":449},{"type":"mousemove","time":41608,"x":771,"y":432},{"type":"mousemove","time":41823,"x":773,"y":410},{"type":"mousemove","time":42027,"x":774,"y":388},{"type":"mousemove","time":42243,"x":774,"y":370},{"type":"mousemove","time":42444,"x":774,"y":365},{"type":"mousemove","time":42656,"x":774,"y":355},{"type":"mousemove","time":42871,"x":774,"y":355},{"type":"mouseup","time":42992,"x":774,"y":355},{"time":42993,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":43007,"x":781,"y":350},{"type":"mousemove","time":43223,"x":789,"y":345},{"type":"mousemove","time":43423,"x":789,"y":345},{"type":"mousemove","time":43636,"x":788,"y":345},{"type":"mousedown","time":43804,"x":788,"y":345},{"type":"mousemove","time":43818,"x":788,"y":344},{"type":"mousemove","time":44022,"x":789,"y":317},{"type":"mousemove","time":44225,"x":789,"y":303},{"type":"mousemove","time":44436,"x":789,"y":294},{"type":"mouseup","time":44588,"x":789,"y":294},{"time":44589,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":44602,"x":788,"y":298},{"type":"mousemove","time":44809,"x":772,"y":337},{"type":"mousemove","time":45023,"x":771,"y":337},{"type":"mousemove","time":45238,"x":771,"y":332},{"type":"mousedown","time":45505,"x":771,"y":332},{"type":"mousemove","time":45519,"x":771,"y":332},{"type":"mousemove","time":45732,"x":769,"y":359},{"type":"mousemove","time":45947,"x":769,"y":370},{"type":"mousemove","time":46155,"x":769,"y":377},{"type":"mousemove","time":46359,"x":770,"y":384},{"type":"mousemove","time":46564,"x":771,"y":388},{"type":"mousemove","time":46922,"x":771,"y":389},{"type":"mousemove","time":47143,"x":771,"y":389},{"type":"mousemove","time":47156,"x":771,"y":389},{"type":"mousemove","time":47370,"x":769,"y":406},{"type":"mousemove","time":47586,"x":768,"y":429},{"type":"mousemove","time":47790,"x":766,"y":443},{"type":"mousemove","time":47998,"x":763,"y":465},{"type":"mousemove","time":48203,"x":763,"y":467},{"type":"mouseup","time":48744,"x":763,"y":467},{"time":48745,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":48758,"x":751,"y":444},{"type":"mousemove","time":48979,"x":697,"y":354},{"type":"mousemove","time":49176,"x":693,"y":352},{"type":"mousemove","time":49386,"x":458,"y":247},{"type":"mousemove","time":49590,"x":446,"y":213},{"type":"mousemove","time":49790,"x":442,"y":198},{"type":"mousedown","time":49821,"x":442,"y":198},{"type":"mouseup","time":49897,"x":442,"y":198},{"time":49898,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":50073,"x":441,"y":198},{"type":"mousemove","time":50279,"x":352,"y":203},{"type":"mousedown","time":50309,"x":352,"y":203},{"type":"mouseup","time":50421,"x":352,"y":203},{"time":50422,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":50488,"x":352,"y":203},{"type":"mousemove","time":50690,"x":635,"y":247},{"type":"mousemove","time":50904,"x":650,"y":255},{"type":"mousemove","time":51107,"x":795,"y":266},{"type":"mousemove","time":51309,"x":777,"y":235},{"type":"mousemove","time":51511,"x":772,"y":235},{"type":"mousemove","time":51721,"x":772,"y":235},{"type":"mousedown","time":51810,"x":772,"y":235},{"type":"mousemove","time":51826,"x":772,"y":239},{"type":"mousemove","time":52034,"x":766,"y":329},{"type":"mousemove","time":52237,"x":766,"y":330},{"type":"mousemove","time":52281,"x":766,"y":331},{"type":"mousemove","time":52505,"x":769,"y":308},{"type":"mouseup","time":52637,"x":769,"y":308},{"time":52638,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":52653,"x":782,"y":325},{"type":"mousemove","time":52874,"x":789,"y":338},{"type":"mousedown","time":53027,"x":789,"y":338},{"type":"mousemove","time":53074,"x":788,"y":329},{"type":"mousemove","time":53280,"x":787,"y":303},{"type":"mousemove","time":53492,"x":786,"y":293},{"type":"mouseup","time":53672,"x":786,"y":292},{"time":53673,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":53702,"x":707,"y":259},{"type":"mousemove","time":53914,"x":458,"y":184},{"type":"mousemove","time":54121,"x":445,"y":199},{"type":"mousedown","time":54208,"x":444,"y":202},{"type":"mousemove","time":54340,"x":444,"y":202},{"type":"mouseup","time":54381,"x":444,"y":202},{"time":54382,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":54695,"x":444,"y":202},{"type":"mousemove","time":54903,"x":678,"y":267},{"type":"mousemove","time":55112,"x":792,"y":307},{"type":"mousemove","time":55324,"x":777,"y":273},{"type":"mousemove","time":55541,"x":770,"y":259},{"type":"mousemove","time":55742,"x":770,"y":259},{"type":"mousedown","time":55888,"x":770,"y":259},{"type":"mousemove","time":55902,"x":770,"y":258},{"type":"mousemove","time":56129,"x":773,"y":214},{"type":"mouseup","time":56344,"x":773,"y":214},{"time":56345,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":56361,"x":773,"y":266},{"type":"mousemove","time":56566,"x":775,"y":425},{"type":"mousemove","time":56793,"x":773,"y":432},{"type":"mousemove","time":57040,"x":776,"y":418},{"type":"mousedown","time":57339,"x":776,"y":418},{"type":"mousemove","time":57353,"x":776,"y":418},{"type":"mousemove","time":57576,"x":774,"y":430},{"type":"mouseup","time":58155,"x":774,"y":430},{"time":58156,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":58424,"x":774,"y":430},{"type":"mousemove","time":58642,"x":771,"y":434},{"type":"mousedown","time":59005,"x":771,"y":434},{"type":"mousemove","time":59020,"x":771,"y":434},{"type":"mousemove","time":59243,"x":767,"y":471},{"type":"mouseup","time":59507,"x":767,"y":471},{"time":59508,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":59522,"x":765,"y":467},{"type":"mousemove","time":59744,"x":582,"y":259},{"type":"mousemove","time":59957,"x":479,"y":231},{"type":"mousemove","time":60157,"x":289,"y":202},{"type":"mousemove","time":60357,"x":189,"y":170},{"type":"mousemove","time":60561,"x":178,"y":145},{"type":"mousemove","time":60771,"x":176,"y":143},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":61748,"target":"select"},{"time":61749,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":61763,"x":274,"y":173},{"type":"mousemove","time":61970,"x":751,"y":261},{"type":"mousemove","time":62185,"x":782,"y":252},{"type":"mousemove","time":62385,"x":566,"y":218},{"type":"mousemove","time":62593,"x":723,"y":235},{"type":"mousemove","time":62810,"x":794,"y":244},{"type":"mousemove","time":62859,"x":799,"y":242},{"type":"mousemove","time":63067,"x":772,"y":231},{"type":"mousemove","time":63275,"x":770,"y":234},{"type":"mousedown","time":63724,"x":770,"y":234},{"type":"mousemove","time":63738,"x":770,"y":235},{"type":"mousemove","time":63945,"x":768,"y":281},{"type":"mousemove","time":64150,"x":769,"y":317},{"type":"mousemove","time":64372,"x":769,"y":324},{"type":"mouseup","time":64522,"x":769,"y":324},{"time":64523,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":64541,"x":773,"y":331},{"type":"mousemove","time":64751,"x":773,"y":345},{"type":"mousemove","time":64989,"x":773,"y":346},{"type":"mousemove","time":65190,"x":770,"y":336},{"type":"mousemove","time":65406,"x":769,"y":327},{"type":"mousemove","time":65644,"x":769,"y":324},{"type":"mousedown","time":65808,"x":769,"y":324},{"type":"mousemove","time":65823,"x":769,"y":322},{"type":"mousemove","time":66032,"x":769,"y":281},{"type":"mousemove","time":66238,"x":769,"y":274},{"type":"mouseup","time":66448,"x":768,"y":274},{"time":66449,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":66468,"x":771,"y":308},{"type":"mousemove","time":66687,"x":774,"y":412},{"type":"mousemove","time":66895,"x":770,"y":438},{"type":"mousemove","time":67107,"x":770,"y":441},{"type":"mousemove","time":67192,"x":770,"y":442},{"type":"mousemove","time":67400,"x":770,"y":460},{"type":"mousemove","time":67608,"x":770,"y":465},{"type":"mousemove","time":67822,"x":770,"y":467},{"type":"mousedown","time":68039,"x":770,"y":467},{"type":"mousemove","time":68053,"x":770,"y":467},{"type":"mousemove","time":68260,"x":770,"y":423},{"type":"mousemove","time":68469,"x":770,"y":398},{"type":"mousemove","time":68680,"x":769,"y":369},{"type":"mousemove","time":68911,"x":770,"y":356},{"type":"mouseup","time":69089,"x":770,"y":356},{"time":69090,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":69108,"x":774,"y":347},{"type":"mousemove","time":69317,"x":785,"y":321},{"type":"mousemove","time":69525,"x":790,"y":314},{"type":"mousedown","time":69939,"x":790,"y":314},{"type":"mousemove","time":69954,"x":790,"y":314},{"type":"mousemove","time":70159,"x":787,"y":355},{"type":"mousemove","time":70380,"x":786,"y":369},{"type":"mousemove","time":70596,"x":786,"y":383},{"type":"mousemove","time":70806,"x":786,"y":386},{"type":"mouseup","time":70942,"x":786,"y":386},{"time":70943,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":70962,"x":786,"y":365},{"type":"mousemove","time":71179,"x":748,"y":297},{"type":"mousemove","time":71389,"x":700,"y":279},{"type":"mousemove","time":71627,"x":693,"y":279},{"type":"mousemove","time":71842,"x":453,"y":248},{"type":"mousemove","time":72056,"x":454,"y":248},{"type":"mousemove","time":72259,"x":401,"y":237},{"type":"mousemove","time":72472,"x":315,"y":217},{"type":"mousemove","time":72688,"x":240,"y":190},{"type":"mousemove","time":72907,"x":236,"y":191},{"type":"mousemove","time":73110,"x":219,"y":195},{"type":"mousemove","time":73322,"x":191,"y":203},{"type":"mousemove","time":73527,"x":138,"y":187},{"type":"mousemove","time":73739,"x":160,"y":173},{"type":"mousemove","time":73956,"x":165,"y":171},{"type":"mousemove","time":74957,"x":164,"y":143},{"type":"mousemove","time":75178,"x":164,"y":143},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":75966,"target":"select"},{"time":75967,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":76035,"x":163,"y":175},{"type":"mousemove","time":76262,"x":162,"y":181},{"type":"mousemove","time":76628,"x":162,"y":181},{"type":"mousemove","time":76842,"x":165,"y":172},{"type":"mousemove","time":77056,"x":165,"y":172},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(5)>select.test-inputs-select-select","value":"1","time":78463,"target":"select"},{"time":78464,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":78497,"x":307,"y":197},{"type":"mousemove","time":78705,"x":529,"y":256},{"type":"mousemove","time":78913,"x":718,"y":308},{"type":"mousemove","time":79123,"x":765,"y":293},{"type":"mousemove","time":79323,"x":776,"y":259},{"type":"mousemove","time":79527,"x":776,"y":245},{"type":"mousemove","time":79738,"x":775,"y":235},{"type":"mousemove","time":79955,"x":774,"y":235},{"type":"mousedown","time":80124,"x":774,"y":235},{"type":"mousemove","time":80139,"x":774,"y":249},{"type":"mousemove","time":80348,"x":771,"y":325},{"type":"mousemove","time":80561,"x":770,"y":340},{"type":"mousemove","time":80762,"x":767,"y":353},{"type":"mousemove","time":80964,"x":769,"y":347},{"type":"mousemove","time":81168,"x":771,"y":334},{"type":"mousemove","time":81374,"x":772,"y":332},{"type":"mouseup","time":81405,"x":772,"y":332},{"time":81406,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":81420,"x":785,"y":354},{"type":"mousemove","time":81631,"x":797,"y":375},{"type":"mousemove","time":81838,"x":788,"y":376},{"type":"mousemove","time":82040,"x":788,"y":376},{"type":"mousedown","time":82255,"x":788,"y":375},{"type":"mousemove","time":82269,"x":788,"y":375},{"type":"mousemove","time":82474,"x":789,"y":342},{"type":"mousemove","time":82683,"x":790,"y":318},{"type":"mousemove","time":82891,"x":790,"y":299},{"type":"mousemove","time":83241,"x":790,"y":298},{"type":"mousemove","time":83442,"x":791,"y":249},{"type":"mousemove","time":83659,"x":790,"y":243},{"type":"mouseup","time":83748,"x":790,"y":243},{"time":83749,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":83864,"x":782,"y":348},{"type":"mousemove","time":84071,"x":775,"y":373},{"type":"mousemove","time":84285,"x":771,"y":371},{"type":"mousemove","time":84519,"x":772,"y":366},{"type":"mousedown","time":84923,"x":772,"y":366},{"type":"mousemove","time":84938,"x":771,"y":370},{"type":"mousemove","time":85142,"x":763,"y":435},{"type":"mousemove","time":85348,"x":758,"y":466},{"type":"mousemove","time":85566,"x":758,"y":471},{"type":"mousemove","time":85809,"x":759,"y":482},{"type":"mousemove","time":86039,"x":759,"y":482},{"type":"mouseup","time":86114,"x":759,"y":482},{"time":86115,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":86130,"x":627,"y":364},{"type":"mousemove","time":86363,"x":466,"y":259},{"type":"mousemove","time":86468,"x":461,"y":253},{"type":"mousemove","time":86673,"x":393,"y":195},{"type":"mousemove","time":86875,"x":391,"y":177},{"type":"mousemove","time":87091,"x":393,"y":171},{"type":"mousemove","time":87309,"x":393,"y":169},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(6)>select.test-inputs-select-select","value":"1","time":88591,"target":"select"},{"time":88592,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":88649,"x":389,"y":173},{"type":"mousemove","time":88857,"x":388,"y":175},{"type":"valuechange","selector":"#main_cartesian_yAxis_dataZoom>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(6)>select.test-inputs-select-select","value":"2","time":89751,"target":"select"},{"time":89752,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":89767,"x":573,"y":172},{"type":"mousemove","time":89975,"x":670,"y":189},{"type":"mousedown","time":90008,"x":670,"y":189},{"type":"mouseup","time":90075,"x":670,"y":189},{"time":90076,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":90160,"x":670,"y":189},{"type":"mousemove","time":90214,"x":670,"y":189},{"type":"mouseup","time":90263,"x":670,"y":189},{"time":90264,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":90457,"x":670,"y":189},{"type":"mouseup","time":90564,"x":670,"y":189},{"time":90565,"delay":400,"type":"screenshot-auto"}],"scrollY":1506,"scrollX":0,"timestamp":1767884112910},{"name":"Action 5","ops":[{"type":"mousemove","time":420,"x":665,"y":224},{"type":"mousemove","time":633,"x":542,"y":170},{"type":"mousemove","time":849,"x":442,"y":122},{"type":"mousemove","time":1051,"x":421,"y":112},{"type":"mousemove","time":1253,"x":412,"y":103},{"type":"mousemove","time":1477,"x":409,"y":101},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"1","time":2929,"target":"select"},{"time":2930,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2944,"x":395,"y":127},{"type":"mousemove","time":3156,"x":395,"y":127},{"type":"mousemove","time":3527,"x":395,"y":126},{"type":"mousemove","time":3740,"x":398,"y":116},{"type":"mousemove","time":3940,"x":399,"y":110},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"0","time":4874,"target":"select"},{"time":4875,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4889,"x":401,"y":86},{"type":"mousemove","time":5123,"x":401,"y":86},{"type":"mousemove","time":5279,"x":401,"y":85},{"type":"mousemove","time":5513,"x":401,"y":85},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":6525,"target":"select"},{"time":6526,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6574,"x":396,"y":92},{"type":"mousemove","time":6801,"x":396,"y":92},{"type":"mousemove","time":6864,"x":397,"y":91},{"type":"mousemove","time":7072,"x":397,"y":86},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":8046,"target":"select"},{"time":8047,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8081,"x":507,"y":83},{"type":"mousemove","time":8282,"x":536,"y":91},{"type":"mousemove","time":8519,"x":539,"y":104},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"1","time":9743,"target":"select"},{"time":9744,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9777,"x":541,"y":112},{"type":"mousemove","time":10007,"x":541,"y":112},{"type":"mousemove","time":10125,"x":541,"y":112},{"type":"mousemove","time":10886,"x":540,"y":112},{"type":"mousemove","time":11101,"x":540,"y":112},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"2","time":12125,"target":"select"},{"time":12126,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12141,"x":480,"y":150},{"type":"mousemove","time":12345,"x":44,"y":370},{"type":"mousemove","time":12558,"x":44,"y":371},{"type":"mousemove","time":12641,"x":44,"y":369},{"type":"mousemove","time":12866,"x":44,"y":357},{"type":"mousemove","time":13077,"x":43,"y":363},{"type":"mousedown","time":13122,"x":43,"y":364},{"type":"mouseup","time":13223,"x":43,"y":364},{"time":13224,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13279,"x":43,"y":364},{"type":"mousedown","time":13685,"x":43,"y":364},{"type":"mouseup","time":13804,"x":43,"y":364},{"time":13805,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13903,"x":45,"y":364},{"type":"mousemove","time":14113,"x":504,"y":175},{"type":"mousemove","time":14325,"x":512,"y":164},{"type":"mousemove","time":14541,"x":538,"y":119},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"1","time":15771,"target":"select"},{"time":15772,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15791,"x":153,"y":310},{"type":"mousemove","time":16005,"x":49,"y":377},{"type":"mousedown","time":16187,"x":49,"y":367},{"type":"mousemove","time":16241,"x":49,"y":367},{"type":"mouseup","time":16305,"x":49,"y":367},{"time":16306,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":16635,"x":49,"y":367},{"type":"mouseup","time":16726,"x":49,"y":367},{"time":16727,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16924,"x":52,"y":366},{"type":"mousemove","time":17133,"x":625,"y":153},{"type":"mousemove","time":17335,"x":611,"y":131},{"type":"mousemove","time":17551,"x":559,"y":115},{"type":"mousemove","time":17776,"x":541,"y":106},{"type":"valuechange","selector":"#main_radar_0>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"0","time":18627,"target":"select"},{"time":18628,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":18643,"x":291,"y":255},{"type":"mousemove","time":18859,"x":62,"y":353},{"type":"mousemove","time":19070,"x":29,"y":373},{"type":"mousemove","time":19283,"x":31,"y":374},{"type":"mousedown","time":19423,"x":41,"y":366},{"type":"mousemove","time":19509,"x":41,"y":366},{"type":"mouseup","time":19558,"x":41,"y":366},{"time":19559,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":19922,"x":41,"y":366},{"type":"mouseup","time":20039,"x":41,"y":366},{"time":20040,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":20323,"x":49,"y":366},{"type":"mousemove","time":20528,"x":338,"y":330},{"type":"mousemove","time":20741,"x":390,"y":349},{"type":"mousemove","time":20949,"x":404,"y":369},{"type":"mousemove","time":21167,"x":405,"y":369},{"type":"mousemove","time":21202,"x":405,"y":369},{"type":"mousemove","time":21420,"x":398,"y":365},{"type":"mousemove","time":21635,"x":414,"y":230},{"type":"mousedown","time":21859,"x":576,"y":210},{"type":"mousemove","time":21875,"x":576,"y":210},{"type":"mouseup","time":21938,"x":576,"y":210},{"time":21939,"delay":400,"type":"screenshot-auto"}],"scrollY":2029.5,"scrollX":0,"timestamp":1767884211915}] \ No newline at end of file diff --git a/test/runTest/marks/axis-align-edge-cases.json b/test/runTest/marks/axis-align-edge-cases.json new file mode 100644 index 0000000000..826d66dadf --- /dev/null +++ b/test/runTest/marks/axis-align-edge-cases.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "The diff is introduced by changing the \"alignTicks\" logic and provided a better precision strategy in both ticks and dataZoom. Expected.", + "type": "Bug Fixing", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1767946162935 + } +] \ No newline at end of file diff --git a/test/runTest/marks/axis-align-lastLabel.json b/test/runTest/marks/axis-align-lastLabel.json index 22f0ac6877..aa980855d1 100644 --- a/test/runTest/marks/axis-align-lastLabel.json +++ b/test/runTest/marks/axis-align-lastLabel.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "The diff is introduced by changing the precision choosing of dataZoom. Intentional.", + "type": "Bug Fixing", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1767944513407 + }, { "link": "https://github.com/apache/echarts/pull/21059", "comment": "Introduce by the `outerBounds` feature that avoid axis name overflowing the canvas by default. In the previous result the label touches the canvas edge by coincidence.", diff --git a/test/runTest/marks/axis-align-ticks-random.json b/test/runTest/marks/axis-align-ticks-random.json new file mode 100644 index 0000000000..f1d2b750d7 --- /dev/null +++ b/test/runTest/marks/axis-align-ticks-random.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "The diff is introduced by changes of alignTicks strategy. Acceptable.", + "type": "Bug Fixing", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1767944488907 + } +] \ No newline at end of file From a6ab2458f32ff0b2cf657f72c58f3267236d75eb Mon Sep 17 00:00:00 2001 From: 100pah Date: Wed, 14 Jan 2026 16:24:13 +0800 Subject: [PATCH 07/31] feat(alignTicks): (1) Fix LogScale precision. (2) Tweak align ticks layout. (3) Remove unreasonable clamp in Interval calcNiceExtent, and clarify the definition of `_niceExtent`. --- src/coord/axisAlignTicks.ts | 200 ++++++++++++++++++++------------ src/coord/axisHelper.ts | 6 +- src/coord/scaleRawExtentInfo.ts | 14 ++- src/scale/Interval.ts | 94 ++++++++------- src/scale/Log.ts | 128 ++++++++------------ src/scale/Scale.ts | 8 +- src/scale/Time.ts | 2 - src/scale/break.ts | 9 +- src/scale/breakImpl.ts | 86 +++++++------- src/scale/helper.ts | 128 ++++++++++++++------ test/axis-align-edge-cases.html | 97 +++++++++++++++- test/axis-align-ticks.html | 5 +- 12 files changed, 479 insertions(+), 298 deletions(-) diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 4c4a6f8ac7..f392c74eb9 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -19,7 +19,8 @@ import { getAcceptableTickPrecision, - mathAbs, mathCeil, mathFloor, mathMax, nice, NICE_MODE_MIN, round + getPrecision, + mathAbs, mathCeil, mathFloor, mathMax, mathRound, nice, NICE_MODE_MIN, quantity, round } from '../util/number'; import IntervalScale from '../scale/Interval'; import { adoptScaleExtentOptionAndPrepare } from './axisHelper'; @@ -28,7 +29,7 @@ import LogScale from '../scale/Log'; import { warn } from '../util/log'; import { increaseInterval, isLogScale, getIntervalPrecision, intervalScaleEnsureValidExtent, - logTransform, + logScaleLogTickPair, } from '../scale/helper'; import { assert } from 'zrender/src/core/util'; import { NullUndefined } from '../util/types'; @@ -42,7 +43,9 @@ export function alignScaleTicks( ): void { const isTargetLogScale = isLogScale(targetScale); const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.linearStub : alignToScale; + const targetScaleLinear = isTargetLogScale ? targetScale.linearStub : targetScale; + const targetLogScaleBase = (targetScale as LogScale).base; const alignToTicks = alignToScaleLinear.getTicks(); const alignToExpNiceTicks = alignToScaleLinear.getTicks({expandToNicedExtent: true}); const alignToSegCount = alignToTicks.length - 1; @@ -67,16 +70,16 @@ export function alignScaleTicks( // matching exactly to ticks of `alignTo` scale. // Adjust min, max based on the extent of alignTo. When min or max is set in alignTo scale - let t0: number; // diff ratio on min irregular segment. 0 <= t0 < 1 - let t1: number; // diff ratio on max irregular segment. 0 <= t1 < 1 - let alignToRegularSegCount: number; // >= 1 + let t0: number; // diff ratio on min not-nice segment. 0 <= t0 < 1 + let t1: number; // diff ratio on max not-nice segment. 0 <= t1 < 1 + let alignToNiceSegCount: number; // >= 1 // Consider ticks of `alignTo`, only these cases below may occur: if (alignToSegCount === 1) { // `alignToTicks` is like: // |--| // In this case, we make the corresponding 2 target ticks "nice". t0 = t1 = 0; - alignToRegularSegCount = 1; + alignToNiceSegCount = 1; } else if (alignToSegCount === 2) { // `alignToTicks` is like: @@ -84,16 +87,16 @@ export function alignScaleTicks( // |-----|-| or // |-----|-----| // Notices that nice ticks do not necessarily exist in this case. - // In this case, we choose the larger segment as the "regular segment" and + // In this case, we choose the larger segment as the "nice segment" and // the corresponding target ticks are made "nice". const interval0 = mathAbs(alignToTicks[0].value - alignToTicks[1].value); const interval1 = mathAbs(alignToTicks[1].value - alignToTicks[2].value); t0 = t1 = 0; if (interval0 === interval1) { - alignToRegularSegCount = 2; + alignToNiceSegCount = 2; } else { - alignToRegularSegCount = 1; + alignToNiceSegCount = 1; if (interval0 < interval1) { t0 = interval0 / interval1; } @@ -107,9 +110,9 @@ export function alignScaleTicks( // |-|-----|-----|-| or // |-----|-----|-| or // |-|-----|-----| or ... - // At least one regular segment is present, and irregular segments are only present on + // At least one nice segment is present, and not-nice segments are only present on // the start and/or the end. - // In this case, ticks corresponding to regular segments are made "nice". + // In this case, ticks corresponding to nice segments are made "nice". const alignToInterval = alignToScaleLinear.getInterval(); t0 = ( 1 - (alignToTicks[0].value - alignToExpNiceTicks[0].value) / alignToInterval @@ -117,28 +120,36 @@ export function alignScaleTicks( t1 = ( 1 - (alignToExpNiceTicks[alignToSegCount].value - alignToTicks[alignToSegCount].value) / alignToInterval ) % 1; - alignToRegularSegCount = alignToSegCount - (t0 ? 1 : 0) - (t1 ? 1 : 0); + alignToNiceSegCount = alignToSegCount - (t0 ? 1 : 0) - (t1 ? 1 : 0); } if (__DEV__) { - assert(alignToRegularSegCount >= 1); + assert(alignToNiceSegCount >= 1); } const targetExtentInfo = adoptScaleExtentOptionAndPrepare(targetScale, targetAxisModel, targetDataExtent); - // NOTE: If `dataZoom` has either start/end not 0% or 100% (indicated by `min/maxDetermined`), we consider - // both min and max fixed; otherwise the result is probably unexpected if we expand the extent out of - // the original min/max, e.g., the expanded extent may cross zero. + // NOTE: + // Consider a case: + // dataZoom controls all Y axes; + // dataZoom end is 90% (maxFixed: true, maxDetermined: true); + // but dataZoom start is 0% (minFixed: false, minDetermined: false); + // In this case, + // `Interval#calcNiceTicks` only uses `targetExtentInfo.max` as the upper bound but may expand the + // lower bound to a "nice" tick and can get an acceptable result. + // But `alignScaleTicks` has to use both `targetExtentInfo.min/max` as the bounds without any expansion, + // otherwise the lower bound may become negative unexpectedly for all positive series data. const hasMinMaxDetermined = targetExtentInfo.minDetermined || targetExtentInfo.maxDetermined; - const targetMinFixed = targetExtentInfo.minFixed || hasMinMaxDetermined; - const targetMaxFixed = targetExtentInfo.maxFixed || hasMinMaxDetermined; - // MEMO: - // - When only `xxxAxis.min` or `xxxAxis.max` is fixed, even "nice" interval can be calculated, ticks - // accumulated based on `min`/`max` can be "nice" only if `min` or `max` is "nice". - // - Generating a "nice" interval in this case may cause the extent have both positive and negative ticks, - // which may be not preferable for all positive (very common) or all negative series data. But it can be - // simply resolved by specifying `xxxAxis.min: 0`/`xxxAxis.max: 0`, so we do not specially handle this - // case here. + const targetMinMaxFixed = [ + targetExtentInfo.minFixed || hasMinMaxDetermined, + targetExtentInfo.maxFixed || hasMinMaxDetermined + ]; + // MEMO: When only `xxxAxis.min` or `xxxAxis.max` is fixed, + // - Even a "nice" interval can be calculated, ticks accumulated based on `min`/`max` can be "nice" only if + // `min` or `max` is a "nice" number. + // - Generating a "nice" interval may cause the extent have both positive and negative ticks, which may be + // not preferable for all positive (very common) or all negative series data. But it can be simply resolved + // by specifying `xxxAxis.min: 0`/`xxxAxis.max: 0`, so we do not specially handle this case here. // Therefore, we prioritize generating "nice" interval over preventing from crossing zero. // e.g., if series data are all positive and the max data is `11739`, // If setting `yAxis.max: 'dataMax'`, ticks may be like: @@ -148,11 +159,15 @@ export function alignScaleTicks( // If setting `yAxis.max: 12000, yAxis.min: 0`, ticks may be like: // `12000, 9000, 6000, 3000, 0` ("nice") - let targetExtent = [targetExtentInfo.min, targetExtentInfo.max]; + let targetRawExtent = [targetExtentInfo.min, targetExtentInfo.max]; if (isTargetLogScale) { - targetExtent = logTransform(targetScale.base, targetExtent); + targetRawExtent = logScaleLogTickPair(targetRawExtent, targetLogScaleBase); } - targetExtent = intervalScaleEnsureValidExtent(targetExtent, {fixMax: targetMaxFixed}); + const targetExtent = intervalScaleEnsureValidExtent(targetRawExtent, targetMinMaxFixed); + const targetMinMaxChanged = [ + targetExtent[0] !== targetRawExtent[0], + targetExtent[1] !== targetRawExtent[1] + ]; let min: number; let max: number; @@ -172,9 +187,9 @@ export function alignScaleTicks( break; } interval = isTargetLogScale - // TODO: A guardcode to avoid infinite loop, but probably it - // should be guranteed by `LogScale` itself. - ? interval * mathMax(targetScale.base, 2) + // TODO: `mathMax(base, 2)` is a guardcode to avoid infinite loop, + // but probably it should be guranteed by `LogScale` itself. + ? interval * mathMax(targetLogScaleBase, 2) : increaseInterval(interval); intervalPrecision = getIntervalPrecision(interval); } @@ -185,11 +200,24 @@ export function alignScaleTicks( } } + function updateMinFromMinNice() { + min = round(minNice - interval * t0, intervalPrecision); + } + function updateMaxFromMaxNice() { + max = round(maxNice + interval * t1, intervalPrecision); + } + function updateMinNiceFromMinT0Interval() { + minNice = t0 ? round(min + interval * t0, intervalPrecision) : min; + } + function updateMaxNiceFromMaxT1Interval() { + maxNice = t1 ? round(max - interval * t1, intervalPrecision) : max; + } + // NOTE: The new calculated `min`/`max` must NOT shrink the original extent; otherwise some series // data may be outside of the extent. They can expand the original extent slightly to align with // ticks of `alignTo`. In this case, more blank space is added but visually fine. - if (targetMinFixed && targetMaxFixed) { + if (targetMinMaxFixed[0] && targetMinMaxFixed[1]) { // Both `min` and `max` are specified (via dataZoom or ec option; consider both Cartesian, radar and // other possible axes). In this case, "nice" ticks can hardly be calculated, but reasonable ticks should // still be calculated whenever possible, especially `intervalPrecision` should be tuned for better @@ -197,90 +225,114 @@ export function alignScaleTicks( min = targetExtent[0]; max = targetExtent[1]; - intervalCount = alignToRegularSegCount; - const rawInterval = (max - min) / (alignToRegularSegCount + t0 + t1); + intervalCount = alignToNiceSegCount; + interval = (max - min) / (alignToNiceSegCount + t0 + t1); // Typically axis pixel extent is ready here. See `create` in `Grid.ts`. const axisPxExtent = targetAxisModel.axis.getExtent(); // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable so far. const pxSpan = mathAbs(axisPxExtent[1] - axisPxExtent[0]); - // We imperically choose `pxDiffAcceptable` as `0.5 / alignToRegularSegCount` for reduce cumulative + // We imperically choose `pxDiffAcceptable` as `0.5 / alignToNiceSegCount` for reduce cumulative // error, otherwise a discernible misalign (> 1px) may occur. // PENDING: We do not find a acceptable precision for LogScale here. // Theoretically it can be addressed but introduce more complexity. Is it necessary? - intervalPrecision = getAcceptableTickPrecision(max - min, pxSpan, 0.5 / alignToRegularSegCount); - interval = round(rawInterval, intervalPrecision); - maxNice = t1 ? round(max - rawInterval * t1, intervalPrecision) : max; - minNice = t0 ? round(min + rawInterval * t0, intervalPrecision) : min; + intervalPrecision = getAcceptableTickPrecision(max - min, pxSpan, 0.5 / alignToNiceSegCount); + updateMinNiceFromMinT0Interval(); + updateMaxNiceFromMaxT1Interval(); + interval = round(interval, intervalPrecision); } else { // Make a minimal enough `interval`, increase it later. // It is a similar logic as `IntervalScale#calcNiceTicks` and `LogScale#calcNiceTicks`. // Axis break is not supported, which is guranteed by the caller of this function. - interval = nice((targetExtent[1] - targetExtent[0]) / alignToRegularSegCount, NICE_MODE_MIN); + const targetSpan = targetExtent[1] - targetExtent[0]; + interval = isTargetLogScale + ? mathMax(quantity(targetSpan), 1) + : nice(targetSpan / alignToNiceSegCount, NICE_MODE_MIN); intervalPrecision = getIntervalPrecision(interval); - if (targetMinFixed) { + if (targetMinMaxFixed[0]) { min = targetExtent[0]; loopIncreaseInterval(function () { - minNice = t0 ? round(min + interval * t0, intervalPrecision) : min; - maxNice = round(minNice + interval * alignToRegularSegCount, intervalPrecision); - max = round(maxNice + interval * t1, intervalPrecision); + updateMinNiceFromMinT0Interval(); + maxNice = round(minNice + interval * alignToNiceSegCount, intervalPrecision); + updateMaxFromMaxNice(); if (max >= targetExtent[1]) { return true; } }); } - else if (targetMaxFixed) { + else if (targetMinMaxFixed[1]) { max = targetExtent[1]; loopIncreaseInterval(function () { - maxNice = t1 ? round(max - interval * t1, intervalPrecision) : max; - minNice = round(maxNice - interval * alignToRegularSegCount, intervalPrecision); - min = round(minNice - interval * t0, intervalPrecision); + updateMaxNiceFromMaxT1Interval(); + minNice = round(maxNice - interval * alignToNiceSegCount, intervalPrecision); + updateMinFromMinNice(); if (min <= targetExtent[0]) { return true; } }); } else { - // Currently we simply lay out ticks of the target scale to the "regular segments" of `alignTo` - // scale for "nice". If unexpected cases occur in future, the strategy can be tuned precisely - // (e.g., make use of irregular segments). loopIncreaseInterval(function () { - // Consider cases that all positive or all negative, try not to cross zero, which is - // preferable in most cases. - if (targetExtent[1] <= 0) { - maxNice = round(mathCeil(targetExtent[1] / interval) * interval, intervalPrecision); - minNice = round(maxNice - interval * alignToRegularSegCount, intervalPrecision); - if (minNice <= targetExtent[0]) { - return true; + minNice = round(mathCeil(targetExtent[0] / interval) * interval, intervalPrecision); + maxNice = round(mathFloor(targetExtent[1] / interval) * interval, intervalPrecision); + // NOTE: + // - `maxNice - minNice >= -interval` here. + // - While `interval` increases, `currIntervalCount` decreases, minimum `-1`. + const currIntervalCount = mathRound((maxNice - minNice) / interval); + if (currIntervalCount <= alignToNiceSegCount) { + const moreCount = alignToNiceSegCount - currIntervalCount; + // Consider cases that negative series data do not make sense (or vice versa), users can + // simply specify `xxxAxis.min/max: 0` to achieve that. But we still optimize it for some + // common default cases whenever possible, especially when ec option `xxxAxis.scale: false` + // (the default), it is usually unexpected if negative (or positive) ticks are introduced. + let moreCountPair: number[]; + const needCrossZero = targetExtentInfo.needCrossZero; + if (needCrossZero && targetExtent[0] === 0) { + // 0 has been included in extent and all positive. + moreCountPair = [0, moreCount]; } - } - else { - minNice = round(mathFloor(targetExtent[0] / interval) * interval, intervalPrecision); - maxNice = round(minNice + interval * alignToRegularSegCount, intervalPrecision); - if (maxNice >= targetExtent[1]) { + else if (needCrossZero && targetExtent[1] === 0) { + // 0 has been included in extent and all negative. + moreCountPair = [moreCount, 0]; + } + else { + // Try to arrange tick in the middle as possible corresponding to the given `alignTo` + // ticks, which is especially preferable in `LogScale`. + const lessHalfCount = mathFloor(moreCount / 2); + moreCountPair = moreCount % 2 === 0 ? [lessHalfCount, lessHalfCount] + : (min + max) < (targetExtent[0] + targetExtent[1]) ? [lessHalfCount, lessHalfCount + 1] + : [lessHalfCount + 1, lessHalfCount]; + } + minNice = round(minNice - interval * moreCountPair[0], intervalPrecision); + maxNice = round(maxNice + interval * moreCountPair[1], intervalPrecision); + updateMinFromMinNice(); + updateMaxFromMaxNice(); + if (min <= targetExtent[0] && max >= targetExtent[1]) { return true; } } }); - min = round(minNice - interval * t0, intervalPrecision); - max = round(maxNice + interval * t1, intervalPrecision); } - - intervalPrecision = null; // Clear for the calling of `setInterval`. } - if (isTargetLogScale) { - min = targetScale.powTick(min, 0, null); - max = targetScale.powTick(max, 1, null); - } + const extentPrecision = isTargetLogScale + ? [ + (targetMinMaxFixed[0] && !targetMinMaxChanged[0]) + ? getPrecision(min) : null, + (targetMinMaxFixed[1] && !targetMinMaxChanged[1]) + ? getPrecision(max) : null + ] + : []; + // NOTE: Must in setExtent -> setInterval order. - targetScale.setExtent(min, max); - targetScale.setInterval({ + targetScaleLinear.setExtent(min, max); + targetScaleLinear.setInterval({ // Even in LogScale, `interval` should not be in log space. interval, intervalCount, intervalPrecision, - niceExtent: [minNice, maxNice] + extentPrecision, + niceExtent: [minNice, maxNice], }); } diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 301bab7a95..4fa38b5ae0 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -174,8 +174,7 @@ export function niceScaleExtent( scale.setExtent(extentInfo.min, extentInfo.max); scale.calcNiceExtent({ splitNumber: model.get('splitNumber'), - fixMin: extentInfo.minFixed, - fixMax: extentInfo.maxFixed, + fixMinMax: [extentInfo.minFixed, extentInfo.maxFixed], minInterval: isIntervalOrTime ? model.get('minInterval') : null, maxInterval: isIntervalOrTime ? model.get('maxInterval') : null }); @@ -337,6 +336,9 @@ export function getDataDimensionsOnAxis(data: SeriesData, axisDim: string): Dime return zrUtil.keys(dataDimMap); } +/** + * FIXME: refactor - merge with `Scale#unionExtentFromData` + */ export function unionAxisExtentFromData(dataExtent: number[], data: SeriesData, axisDim: string): void { if (data) { zrUtil.each(getDataDimensionsOnAxis(data, axisDim), function (dim) { diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 8ff4e78a0e..f3ae0b9238 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -38,10 +38,11 @@ export interface ScaleRawExtentResult { min: number; max: number; - // `minFixed`/`maxFixed` marks that: - // - `xxxAxis.min/max` are user specified, or - // - `minDetermined/maxDetermined` are `true` - // so it should be used directly in the final extent without any other "nice strategy". + // `minFixed`/`maxFixed` is `true` iff: + // - ec option `xxxAxis.min/max` are specified, or + // - `scaleRawExtentResult.minDetermined/maxDetermined` are `true` + // They typically suggest axes to use `scaleRawExtentResult.min/max` directly + // as their bounds, instead of expanding the extent by some "nice strategy". readonly minFixed: boolean; readonly maxFixed: boolean; @@ -51,6 +52,7 @@ export interface ScaleRawExtentResult { // Mark that the axis should be blank. readonly isBlank: boolean; + readonly needCrossZero: boolean; } export class ScaleRawExtentInfo { @@ -250,7 +252,8 @@ export class ScaleRawExtentInfo { || (isOrdinal && !axisDataLen); // If data extent modified, need to recalculated to ensure cross zero. - if (this._needCrossZero) { + const needCrossZero = this._needCrossZero; + if (needCrossZero) { // Axis is over zero and min is not set if (min > 0 && max > 0 && !minFixed) { min = 0; @@ -295,6 +298,7 @@ export class ScaleRawExtentInfo { minDetermined: minDetermined, maxDetermined: maxDetermined, isBlank: isBlank, + needCrossZero: needCrossZero, }; } diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index 98ca9daec0..a2b7cf9a3f 100644 --- a/src/scale/Interval.ts +++ b/src/scale/Interval.ts @@ -24,7 +24,7 @@ import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; import * as helper from './helper'; import {ScaleTick, ParsedAxisBreakList, ScaleDataValue, NullUndefined} from '../util/types'; import { getScaleBreakHelper } from './break'; -import { assert } from 'zrender/src/core/util'; +import { assert, retrieve2 } from 'zrender/src/core/util'; class IntervalScale extends Scale { @@ -34,13 +34,30 @@ class IntervalScale e // Step is calculated in adjustExtent. protected _interval: number = 0; protected _intervalPrecision: number = 2; - // `_intervalCount` effectively specifies the number of "nice segment". This is for special cases, - // such as `alignTo: true` and min max are fixed. In this case, `_interval` may be specified with - // a "not-nice" value and needs to be rounded with `_intervalPrecision` for better appearance. Then - // merely accumulating `_interval` may generate incorrect number of ticks. So `_intervalCount` is - // required to specify the expected tick number. + protected _extentPrecision: number[] = []; + /** + * `_intervalCount` effectively specifies the number of "nice segments". This is for special cases, + * such as `alignTicks: true` and min max are fixed. In this case, `_interval` may be specified with + * a "not-nice" value and needs to be rounded with `_intervalPrecision` for better appearance. Then + * merely accumulating `_interval` may generate incorrect number of ticks due to cumulative errors. + * So `_intervalCount` is required to specify the expected nice ticks number. + * Should ensure `_intervalCount >= -1`, + * where `-1` means no nice tick (e.g., `_extent: [5.2, 5.8], _interval: 1`), + * and `0` means only one nice tick (e.g., `_extent: [5, 5.8], _interval: 1`). + * @see setInterval + */ private _intervalCount: number | NullUndefined = undefined; - // Should ensure: `_extent[0] <= _niceExtent[0] && _niceExtent[1] <= _extent[1]` + /** + * Should ensure: + * `_extent[0] <= _niceExtent[0] && _niceExtent[1] <= _extent[1]` + * But NOTICE: + * `_niceExtent[0] - _niceExtent[1] <= _interval`, rather than always `< 0`, + * because `_niceExtent` is typically calculated by + * `[ Math.ceil(_extent[0] / _interval) * _interval, Math.floor(_extent[1] / _interval) * _interval ]`. + * e.g., `_extent: [5.2, 5.8]` with interval `1` will get `_niceExtent: [6, 5]`. + * e.g., `_extent: [5, 5.8]` with interval `1` will get `_niceExtent: [5, 5]`. + * @see setInterval + */ protected _niceExtent: [number, number]; @@ -90,53 +107,43 @@ class IntervalScale e /** * @final override is DISALLOWED. */ - setInterval({interval, intervalCount, intervalPrecision, niceExtent}: { + setInterval({interval, intervalCount, intervalPrecision, extentPrecision, niceExtent}: { interval?: number | NullUndefined; + // See comments of `_intervalCount`. intervalCount?: number | NullUndefined; intervalPrecision?: number | NullUndefined; + extentPrecision?: number[] | NullUndefined; niceExtent?: number[]; }): void { - const intervalCountSpecified = intervalCount != null; + const extent = this._extent; + if (__DEV__) { assert(interval != null); - if (intervalCountSpecified) { + if (intervalCount != null) { assert( - intervalCount > 0 + intervalCount >= -1 && intervalPrecision != null // Do not support intervalCount on axis break currently. && !this.hasBreaks() ); } - } - - const extent = this._extent; - if (__DEV__) { if (niceExtent != null) { - assert( - isFinite(niceExtent[0]) && isFinite(niceExtent[1]) - && extent[0] <= niceExtent[0] && niceExtent[1] <= extent[1] - ); + assert(isFinite(niceExtent[0]) && isFinite(niceExtent[1])); + assert(extent[0] <= niceExtent[0] && niceExtent[1] <= extent[1]); + assert(round(niceExtent[0] - niceExtent[1], getPrecision(interval)) <= interval); } } - niceExtent = this._niceExtent = niceExtent != null - ? niceExtent.slice() as [number, number] + + // Set or clear + this._niceExtent = + niceExtent != null ? niceExtent.slice() as [number, number] // Dropped the auto calculated niceExtent and use user-set extent. // We assume users want to set both interval and extent to get a better result. : extent.slice() as [number, number]; - this._interval = interval; - - if (!intervalCountSpecified) { - // This is for cases of "nice" interval. - this._intervalCount = undefined; // Clear - this._intervalPrecision = helper.getIntervalPrecision(interval); - } - else { - // This is for cases of "not-nice" interval, typically min max are fixed and - // axis alignment is required. - this._intervalCount = intervalCount; - this._intervalPrecision = intervalPrecision; - } + this._intervalCount = intervalCount; + this._intervalPrecision = retrieve2(intervalPrecision, helper.getIntervalPrecision(interval)); + this._extentPrecision = extentPrecision || []; } /** @@ -191,13 +198,17 @@ class IntervalScale e ; niceTickIdx++ ) { + // Consider case `_extent: [5.2, 5.8], _niceExtent: [6, 5], interval: 1`, + // `_intervalCount` makes sense iff `-1`. + // Consider case `_extent: [5, 5.8], _niceExtent: [5, 5], interval: 1`, + // `_intervalCount` makes sense iff `0`. if (intervalCount == null) { - if (tick > niceTickExtent[1]) { + if (tick > niceTickExtent[1] || !isFinite(tick) || !isFinite(niceTickExtent[1])) { break; } } else { - if (niceTickIdx > intervalCount) { // ticks number should be `intervalCount + 1` + if (niceTickIdx > intervalCount) { // nice ticks number should be `intervalCount + 1` break; } // Consider cumulative error, especially caused by rounding, the last nice @@ -398,12 +409,13 @@ class IntervalScale e calcNiceExtent(opt: { splitNumber: number, // By default 5. // Do not modify the original extent[0]/extent[1] except for an invalid extent. - fixMin?: boolean, - fixMax?: boolean, + fixMinMax?: boolean[], // [fixMin, fixMax] minInterval?: number, maxInterval?: number }): void { - let extent = helper.intervalScaleEnsureValidExtent(this._extent, opt); + const fixMinMax = opt.fixMinMax || []; + + let extent = helper.intervalScaleEnsureValidExtent(this._extent, fixMinMax); this._innerSetExtent(extent[0], extent[1]); extent = this._extent.slice() as [number, number]; @@ -412,10 +424,10 @@ class IntervalScale e const interval = this._interval; const intervalPrecition = this._intervalPrecision; - if (!opt.fixMin) { + if (!fixMinMax[0]) { extent[0] = round(mathFloor(extent[0] / interval) * interval, intervalPrecition); } - if (!opt.fixMax) { + if (!fixMinMax[1]) { extent[1] = round(mathCeil(extent[1] / interval) * interval, intervalPrecition); } this._innerSetExtent(extent[0], extent[1]); diff --git a/src/scale/Log.ts b/src/scale/Log.ts index d7be770575..ea32d80054 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -21,7 +21,8 @@ import * as zrUtil from 'zrender/src/core/util'; import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; import { mathFloor, mathCeil, mathPow, mathLog, - round, quantity, getPrecision + round, quantity, getPrecision, + mathMax, } from '../util/number'; // Use some method of IntervalScale @@ -31,14 +32,18 @@ import { ScaleTick, NullUndefined } from '../util/types'; -import { ensureValidSplitNumber, fixNiceExtent, getIntervalPrecision, logTransform } from './helper'; +import { + ensureValidSplitNumber, getIntervalPrecision, + logScalePowTickPair, logScalePowTick, logScaleLogTickPair, + getExtentPrecision +} from './helper'; import SeriesData from '../data/SeriesData'; import { getScaleBreakHelper } from './break'; const LINEAR_STUB_METHODS = [ - 'getExtent', 'getTicks', 'getInterval' - // Keep no setting method to mitigate vulnerability. + 'getExtent', 'getTicks', 'getInterval', + 'setExtent', 'setInterval', ] as const; /** @@ -46,7 +51,8 @@ const LINEAR_STUB_METHODS = [ * - The supper class (`IntervalScale`) and its member fields (such as `this._extent`, * `this._interval`, `this._niceExtent`) provides linear tick arrangement (logarithm applied). * - `_originalScale` (`IntervalScale`) is used to save some original info - * (before logarithm applied, such as raw extent). + * (before logarithm applied, such as raw extent; but may be still invalid, and not sync to the + * calculated ("nice") extent). */ class LogScale extends IntervalScale { @@ -57,9 +63,6 @@ class LogScale extends IntervalScale { private _originalScale = new IntervalScale(); - // `[fixMin, fixMax]` - private _fixMinMax: boolean[] = [false, false]; - linearStub: Pick; constructor(logBase: number | NullUndefined, settings?: ScaleSettingDefault) { @@ -84,30 +87,35 @@ class LogScale extends IntervalScale { * @param Whether expand the ticks to niced extent. */ getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { - const extent = this._extent; + const base = this.base; + const originalScale = this._originalScale; const scaleBreakHelper = getScaleBreakHelper(); + const extent = this._extent; + const extentPrecision = this._extentPrecision; return zrUtil.map(super.getTicks(opt || {}), function (tick) { + const val = tick.value; + let powVal = logScalePowTick( + val, + base, + getExtentPrecision(val, extent, extentPrecision) + ); + let vBreak; - let brkRoundingCriterion; if (scaleBreakHelper) { - const transformed = scaleBreakHelper.getTicksLogTransformBreak( + const brkPowResult = scaleBreakHelper.getTicksPowBreak( tick, - this.base, - this._originalScale._innerGetBreaks(), - fixRoundingError + base, + originalScale._innerGetBreaks(), + extent, + extentPrecision ); - vBreak = transformed.vBreak; - brkRoundingCriterion = transformed.brkRoundingCriterion; + if (brkPowResult) { + vBreak = brkPowResult.vBreak; + powVal = brkPowResult.tickPowValue; + } } - const val = tick.value; - const powVal = this.powTick( - val, - val === extent[1] ? 1 : val === extent[0] ? 0 : null, - brkRoundingCriterion - ); - return { value: powVal, break: vBreak, @@ -122,55 +130,25 @@ class LogScale extends IntervalScale { setExtent(start: number, end: number): void { // [CAVEAT]: If modifying this logic, must sync to `_initLinearStub`. this._originalScale.setExtent(start, end); - const loggedExtent = logTransform(this.base, [start, end]); + const loggedExtent = logScaleLogTickPair([start, end], this.base); super.setExtent(loggedExtent[0], loggedExtent[1]); } getExtent() { const extent = super.getExtent(); - return [ - this.powTick(extent[0], 0, null), - this.powTick(extent[1], 1, null) - ] as [number, number]; + return logScalePowTickPair( + extent, + this.base, + this._extentPrecision + ); } unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { this._originalScale.unionExtentFromData(data, dim); - const loggedOther = logTransform(this.base, data.getApproximateExtent(dim), true); + const loggedOther = logScaleLogTickPair(data.getApproximateExtent(dim), this.base, true); this._innerUnionExtent(loggedOther); } - /** - * fixMin/Max and rounding error are addressed. - */ - powTick( - // `val` should be in the linear space. - val: number, - // `0`: `value` is `min`; - // `1`: `value` is `max`; - // `NullUndefined`: others. - extentIdx: 0 | 1 | NullUndefined, - fallbackRoundingCriterion: number | NullUndefined - ): number { - // NOTE: `Math.pow(10, integer)` has no rounding error. - // PENDING: other base? - let powVal = mathPow(this.base, val); - - // Fix #4158 - // NOTE: Even when `fixMin/Max` is `true`, `pow(base, this._extent[0]/[1])` may be still - // not equal to `this._originalScale.getExtent()[0]`/`[1]` in invalid extent case. - // So we always call `Math.pow`. - const roundingCriterion = this._fixMinMax[extentIdx] - ? this._originalScale.getExtent()[extentIdx] - : fallbackRoundingCriterion; - - if (roundingCriterion != null) { - powVal = fixRoundingError(powVal, roundingCriterion); - } - - return powVal; - } - /** * Update interval and extent of intervals for nice ticks * @param splitNumber default 10 Given approx tick number @@ -183,7 +161,9 @@ class LogScale extends IntervalScale { return; } - let interval = quantity(span); + // Interval should be integer + let interval = mathMax(quantity(span), 1); + const err = splitNumber / span * interval; // Filter ticks to get closer to the desired count. @@ -192,19 +172,12 @@ class LogScale extends IntervalScale { interval *= 10; } - // Interval should be integer - while (!isNaN(interval) && Math.abs(interval) < 1 && Math.abs(interval) > 0) { - interval *= 10; - } - const intervalPrecision = getIntervalPrecision(interval); const niceExtent = [ round(mathCeil(extent[0] / interval) * interval, intervalPrecision), round(mathFloor(extent[1] / interval) * interval, intervalPrecision) ] as [number, number]; - fixNiceExtent(niceExtent, extent); - this._interval = interval; this._intervalPrecision = intervalPrecision; this._niceExtent = niceExtent; @@ -214,14 +187,20 @@ class LogScale extends IntervalScale { calcNiceExtent(opt: { splitNumber: number, - fixMin?: boolean, - fixMax?: boolean, + fixMinMax?: boolean[], minInterval?: number, maxInterval?: number }): void { + const oldExtent = this._extent.slice() as [number, number]; super.calcNiceExtent(opt); - - this._fixMinMax = [!!opt.fixMin, !!opt.fixMax]; + const newExtent = this._extent; + + this._extentPrecision = [ + (opt.fixMinMax && opt.fixMinMax[0] && oldExtent[0] === newExtent[0]) + ? getPrecision(newExtent[0]) : null, + (opt.fixMinMax && opt.fixMinMax[1] && oldExtent[1] === newExtent[1]) + ? getPrecision(newExtent[1]) : null + ]; } contain(val: number): boolean { @@ -257,11 +236,6 @@ class LogScale extends IntervalScale { } -function fixRoundingError(val: number, originalVal: number): number { - return round(val, getPrecision(originalVal)); -} - - Scale.registerClass(LogScale); export default LogScale; diff --git a/src/scale/Scale.ts b/src/scale/Scale.ts index 801e2a2eb8..f462665729 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -244,13 +244,7 @@ abstract class Scale ): void; abstract calcNiceExtent( - opt?: { - splitNumber?: number, - fixMin?: boolean, - fixMax?: boolean, - minInterval?: number, - maxInterval?: number - } + opt?: {} ): void; /** diff --git a/src/scale/Time.ts b/src/scale/Time.ts index eb682440c9..3549384d6f 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -254,8 +254,6 @@ class TimeScale extends IntervalScale { calcNiceExtent( opt?: { splitNumber?: number, - fixMin?: boolean, - fixMax?: boolean, minInterval?: number, maxInterval?: number } diff --git a/src/scale/break.ts b/src/scale/break.ts index ccac046806..2f994b6f06 100644 --- a/src/scale/break.ts +++ b/src/scale/break.ts @@ -114,15 +114,16 @@ export type ScaleBreakHelper = { ): ( TReturnIdx extends false ? TItem[][] : number[][] ); - getTicksLogTransformBreak( + getTicksPowBreak( tick: ScaleTick, logBase: number, logOriginalBreaks: ParsedAxisBreakList, - fixRoundingError: (val: number, originalVal: number) => number + extent: number[], + extentPrecision: (number | NullUndefined)[], ): { - brkRoundingCriterion: number | NullUndefined; + tickPowValue: number | NullUndefined; vBreak: VisualAxisBreak | NullUndefined; - }; + } | NullUndefined; logarithmicParseBreaksFromOption( breakOptionList: AxisBreakOption[], logBase: number, diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index 67d2eaa657..8c5938f45f 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -17,7 +17,7 @@ * under the License. */ -import { assert, clone, each, find, isString, map, trim } from 'zrender/src/core/util'; +import { assert, clone, each, find, isString, map, retrieve2, trim } from 'zrender/src/core/util'; import { NullUndefined, ParsedAxisBreak, ParsedAxisBreakList, AxisBreakOption, AxisBreakOptionIdentifierInAxis, ScaleTick, VisualAxisBreak, @@ -25,8 +25,9 @@ import { import { error } from '../util/log'; import type Scale from './Scale'; import { ScaleBreakContext, AxisBreakParsingResult, registerScaleBreakHelperImpl, ParamPruneByBreak } from './break'; -import { round as fixRound } from '../util/number'; +import { getPrecision, mathMax, mathMin, mathRound } from '../util/number'; import { AxisLabelFormatterExtraParams } from '../coord/axisCommonTypes'; +import { getExtentPrecision, logScaleLogTick, logScaleLogTickPair, logScalePowTick } from './helper'; /** * @caution @@ -83,7 +84,7 @@ class ScaleBreakContextImpl implements ScaleBreakContext { const multiple = estimateNiceMultiple(tickVal, brk.vmax); if (__DEV__) { // If not, it may cause dead loop or not nice tick. - assert(multiple >= 0 && Math.round(multiple) === multiple); + assert(multiple >= 0 && mathRound(multiple) === multiple); } return multiple; } @@ -320,7 +321,7 @@ function updateAxisBreakGapReal( if (gapParsed.type === 'tpPrct') { brk.gapReal = gapPrctSum !== 0 // prctBrksGapRealSum is supposed to be non-negative but add a safe guard - ? Math.max(prctBrksGapRealSum, 0) * gapParsed.val / gapPrctSum : 0; + ? mathMax(prctBrksGapRealSum, 0) * gapParsed.val / gapPrctSum : 0; } if (gapParsed.type === 'tpAbs') { brk.gapReal = gapParsed.val; @@ -430,8 +431,8 @@ function clampBreakByExtent( brk: ParsedAxisBreak, scaleExtent: [number, number] ): NullUndefined | ParsedAxisBreak { - const vmin = Math.max(brk.vmin, scaleExtent[0]); - const vmax = Math.min(brk.vmax, scaleExtent[1]); + const vmin = mathMax(brk.vmin, scaleExtent[0]); + const vmax = mathMin(brk.vmax, scaleExtent[1]); return ( vmin < vmax || (vmin === vmax && vmin > scaleExtent[0] && vmin < scaleExtent[1]) @@ -619,46 +620,47 @@ function retrieveAxisBreakPairs( return result; } -function getTicksLogTransformBreak( +function getTicksPowBreak( tick: ScaleTick, logBase: number, logOriginalBreaks: ParsedAxisBreakList, - fixRoundingError: (val: number, originalVal: number) => number + extent: number[], + extentPrecision: (number | NullUndefined)[], ): { - brkRoundingCriterion: number; + tickPowValue: number; vBreak: VisualAxisBreak | NullUndefined; -} { - let vBreak: VisualAxisBreak | NullUndefined; - let brkRoundingCriterion: number; - - if (tick.break) { - const brk = tick.break.parsedBreak; - const originalBreak = find(logOriginalBreaks, brk => identifyAxisBreak( - brk.breakOption, tick.break.parsedBreak.breakOption - )); - const vmin = fixRoundingError(Math.pow(logBase, brk.vmin), originalBreak.vmin); - const vmax = fixRoundingError(Math.pow(logBase, brk.vmax), originalBreak.vmax); - const gapParsed = { - type: brk.gapParsed.type, - val: brk.gapParsed.type === 'tpAbs' - ? fixRound(Math.pow(logBase, brk.vmin + brk.gapParsed.val)) - vmin - : brk.gapParsed.val, - }; - vBreak = { - type: tick.break.type, - parsedBreak: { - breakOption: brk.breakOption, - vmin, - vmax, - gapParsed, - gapReal: brk.gapReal, - } - }; - brkRoundingCriterion = originalBreak[tick.break.type]; + // Return: If not found, return null/undefined. +} | NullUndefined { + if (!tick.break) { + return; } + const brk = tick.break.parsedBreak; + const originalBreak = find(logOriginalBreaks, brk => identifyAxisBreak( + brk.breakOption, tick.break.parsedBreak.breakOption + )); + const minPrecision = getExtentPrecision(brk.vmin, extent, extentPrecision); + const maxPrecision = getExtentPrecision(brk.vmax, extent, extentPrecision); + // NOTE: `tick.break` may be clamped by scale extent. For consistency we always + // pow back, or heuristically use the user input original break to obtain an + // acceptable rounding precision for display. + const vmin = logScalePowTick(brk.vmin, logBase, retrieve2(minPrecision, getPrecision(originalBreak.vmin))); + const vmax = logScalePowTick(brk.vmax, logBase, retrieve2(maxPrecision, getPrecision(originalBreak.vmax))); + const parsedBreak = { + vmin, + vmax, + // They are not changed by extent clamping. + breakOption: brk.breakOption, + gapParsed: clone(originalBreak.gapParsed), + gapReal: brk.gapReal, + }; + const vBreak = { + type: tick.break.type, + parsedBreak, + }; + return { - brkRoundingCriterion, + tickPowValue: parsedBreak[vBreak.type], vBreak, }; } @@ -675,14 +677,12 @@ function logarithmicParseBreaksFromOption( const parsedOriginal = parseAxisBreakOption(breakOptionList, parse, opt); const parsedLogged = parseAxisBreakOption(breakOptionList, parse, opt); - const loggedBase = Math.log(logBase); parsedLogged.breaks = map(parsedLogged.breaks, brk => { - const vmin = Math.log(brk.vmin) / loggedBase; - const vmax = Math.log(brk.vmax) / loggedBase; + const [vmin, vmax] = logScaleLogTickPair([brk.vmin, brk.vmax], logBase, true); const gapParsed = { type: brk.gapParsed.type, val: brk.gapParsed.type === 'tpAbs' - ? (Math.log(brk.vmin + brk.gapParsed.val) / loggedBase) - vmin + ? logScaleLogTick(brk.vmin + brk.gapParsed.val, logBase, true) - vmin : brk.gapParsed.val, }; return { @@ -722,7 +722,7 @@ export function installScaleBreakHelper(): void { identifyAxisBreak, serializeAxisBreakIdentifier, retrieveAxisBreakPairs, - getTicksLogTransformBreak, + getTicksPowBreak, logarithmicParseBreaksFromOption, makeAxisLabelFormatterParamBreak, }); diff --git a/src/scale/helper.ts b/src/scale/helper.ts index f89abca5d1..bb10ae051d 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -17,7 +17,11 @@ * under the License. */ -import {getPrecision, round, nice, quantityExponent, mathPow, mathMax, mathRound} from '../util/number'; +import { + getPrecision, round, nice, quantityExponent, + mathPow, mathMax, mathRound, + mathLog, mathAbs, mathFloor, mathCeil, mathMin +} from '../util/number'; import IntervalScale from './Interval'; import LogScale from './Log'; import type Scale from './Scale'; @@ -89,13 +93,11 @@ export function intervalScaleNiceTicks( } const precision = result.intervalPrecision = getIntervalPrecision(interval); // Niced extent inside original extent - const niceTickExtent = result.niceTickExtent = [ - round(Math.ceil(extent[0] / interval) * interval, precision), - round(Math.floor(extent[1] / interval) * interval, precision) + result.niceTickExtent = [ + round(mathCeil(extent[0] / interval) * interval, precision), + round(mathFloor(extent[1] / interval) * interval, precision) ]; - fixNiceExtent(niceTickExtent, extent); - return result; } @@ -133,26 +135,6 @@ export function getIntervalPrecision(niceInterval: number): number { return getPrecision(niceInterval) + 2; } - -function clamp( - niceTickExtent: [number, number], idx: number, extent: [number, number] -): void { - niceTickExtent[idx] = Math.max(Math.min(niceTickExtent[idx], extent[1]), extent[0]); -} - -// In some cases (e.g., splitNumber is 1), niceTickExtent may be out of extent. -export function fixNiceExtent( - niceTickExtent: [number, number], extent: [number, number] -): void { - !isFinite(niceTickExtent[0]) && (niceTickExtent[0] = extent[0]); - !isFinite(niceTickExtent[1]) && (niceTickExtent[1] = extent[1]); - clamp(niceTickExtent, 0, extent); - clamp(niceTickExtent, 1, extent); - if (niceTickExtent[0] > niceTickExtent[1]) { - niceTickExtent[0] = niceTickExtent[1]; - } -} - export function contain(val: number, extent: [number, number]): boolean { return val >= extent[0] && val <= extent[1]; } @@ -192,17 +174,79 @@ function scale( return val * (extent[1] - extent[0]) + extent[0]; } -export function logTransform(base: number, extent: number[], noClampNegative?: boolean): [number, number] { - const loggedBase = Math.log(base); +/** + * @see logScaleLogTick + */ +export function logScaleLogTickPair( + pair: number[], + base: number, + noClampNegative?: boolean +): [number, number] { return [ - // log(negative) is NaN, so safe guard here. - // PENDING: But even getting a -Infinity still does not make sense in extent. - // Just keep it as is, getting a NaN to make some previous cases works by coincidence. - Math.log(noClampNegative ? extent[0] : Math.max(0, extent[0])) / loggedBase, - Math.log(noClampNegative ? extent[1] : Math.max(0, extent[1])) / loggedBase + logScaleLogTick(pair[0], base, noClampNegative), + logScaleLogTick(pair[1], base, noClampNegative) ]; } +export function logScaleLogTick( + val: number, + base: number, + noClampNegative?: boolean +): number { + // log(negative) is NaN, so safe guard here. + // PENDING: But even getting a -Infinity still does not make sense in extent. + // Just keep it as is, getting a NaN to make some previous cases works by coincidence. + return mathLog(noClampNegative ? val : mathMax(0, val)) / mathLog(base); + // NOTE: rounding error may happen above, typically expecting `log10(1000)` but actually + // getting `2.9999999999999996`, but generally it does not matter since they are not + // used to display. +} + +/** + * @see logScalePowTick + */ +export function logScalePowTickPair( + linearPair: number[], + base: number, + precisionPair: (number | NullUndefined)[], +): [number, number] { + return [ + logScalePowTick(linearPair[0], base, precisionPair[0]), + logScalePowTick(linearPair[1], base, precisionPair[1]) + ] as [number, number]; +} + +/** + * Cumulative rounding errors cause the logarithm operation to become non-invertible by simply exponentiation. + * - `Math.pow(10, integer)` itself has no rounding error. But, + * - If `linearTickVal` is generated internally by `calcNiceTicks`, it may be still "not nice" (not an integer) + * when it is `extent[i]`. + * - If `linearTickVal` is generated outside (e.g., by `alignScaleTicks`) and set by `setExtent`, + * `logScaleLogTickPair` may already have introduced rounding errors even for "nice" values. + * But invertible is required when the original `extent[i]` need to be respected, or "nice" ticks need to be + * displayed instead of something like `5.999999999999999`, which is addressed in this function by providing + * a `precision`. + * See also `#4158`. + */ +export function logScalePowTick( + // `tickVal` should be in the linear space. + linearTickVal: number, + base: number, + precision: number | NullUndefined, +): number { + + // NOTE: Even when min/max is required to be fixed, `pow(base, tickVal)` is not necessarily equal to + // `originalPowExtent[0]`/`[1]`. e.g., when `originalPowExtent` is a invalid extent but + // `tickVal` has been adjusted to make it valid. So we always use `Math.pow`. + let powVal = mathPow(base, linearTickVal); + + if (precision != null) { + powVal = round(powVal, precision); + } + + return powVal; +} + /** * A valid extent is: * - No non-finite number. @@ -215,9 +259,7 @@ export function logTransform(base: number, extent: number[], noClampNegative?: b */ export function intervalScaleEnsureValidExtent( rawExtent: number[], - opt: { - fixMax?: boolean - } + fixMinMax: boolean[], ): number[] { const extent = rawExtent.slice(); // If extent start and end are same, expand them @@ -225,13 +267,13 @@ export function intervalScaleEnsureValidExtent( if (extent[0] !== 0) { // Expand extent // Note that extents can be both negative. See #13154 - const expandSize = Math.abs(extent[0]); + const expandSize = mathAbs(extent[0]); // In the fowllowing case // Axis has been fixed max 100 // Plus data are all 100 and axis extent are [100, 100]. // Extend to the both side will cause expanded max is larger than fixed max. // So only expand to the smaller side. - if (!opt.fixMax) { + if (!fixMinMax[1]) { extent[1] += expandSize / 2; extent[0] -= expandSize / 2; } @@ -262,3 +304,13 @@ export function ensureValidSplitNumber( rawSplitNumber = rawSplitNumber || defaultSplitNumber; return mathRound(mathMax(rawSplitNumber, 1)); } + +export function getExtentPrecision( + val: number, + extent: number[], + extentPrecision: (number | NullUndefined)[], +): number | NullUndefined { + return val === extent[0] ? extentPrecision[0] + : val === extent[1] ? extentPrecision[1] + : null; +} diff --git a/test/axis-align-edge-cases.html b/test/axis-align-edge-cases.html index d851f1bdd4..4c1bc4b887 100644 --- a/test/axis-align-edge-cases.html +++ b/test/axis-align-edge-cases.html @@ -42,12 +42,13 @@ - +
+
@@ -734,13 +735,103 @@ create_case_main_cartesian_0( echarts, 'main_cartesian_0_logIntegerData', - 'The right yAxis is "log"; the right series are integer Data', + 'The right yAxis is **"log"**; the right series are integer Data', TEST_DATA_INTEGER ); }); // End of `require` + + + + + diff --git a/test/axis-align-ticks.html b/test/axis-align-ticks.html index b0a761a848..e22f2a99c8 100644 --- a/test/axis-align-ticks.html +++ b/test/axis-align-ticks.html @@ -40,7 +40,7 @@
-
+
@@ -228,6 +228,7 @@ var option = { legend: {}, + tooltip: {}, xAxis: { type: 'category', name: 'x', @@ -258,7 +259,7 @@ ] } - var chart = testHelper.create(echarts, 'main3', { + var chart = testHelper.create(echarts, 'main_Log_axis_can_alignTicks_to_value_axis', { title: [ 'Log axis can alignTicks to value axis' ], From 18a23a87585cc8a1575b63dcc4060af5007330fc Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 19 Jan 2026 02:30:47 +0800 Subject: [PATCH 08/31] refactor(scale): For readability and maintainability (1) Migrate `calcNiceTicks` and `calcNiceExtent` from Scale class override member functions to plain functions, similar to `axisAlignTicks`. Previously it's hard to modify and error-prone. (2) Remove unnecessary override on Scale class hierarchy and limit override usage, which is difficult to understand and error-prone. (3) Simplify the inheritance - shift `LogScale` and `TimeScale` inheritance from `IntervalScale` to `Scale`. (4) Clarify the impl of `IntervalScale` - uniform the parameters setting entry for both "nice ticks" and "align ticks". --- src/component/dataZoom/AxisProxy.ts | 6 +- src/component/timeline/SliderTimelineView.ts | 40 +-- src/component/timeline/TimelineAxis.ts | 6 +- src/component/timeline/TimelineModel.ts | 4 - src/coord/axisAlignTicks.ts | 29 +- src/coord/axisCommonTypes.ts | 3 + src/coord/axisHelper.ts | 61 ++-- src/coord/axisNiceTicks.ts | 272 +++++++++++++++++ src/coord/cartesian/Grid.ts | 6 +- .../cartesian/defaultAxisExtentFromData.ts | 4 +- src/coord/parallel/Parallel.ts | 3 +- src/coord/polar/polarCreator.ts | 6 +- src/coord/radar/Radar.ts | 2 +- src/coord/single/Single.ts | 3 +- src/export/api/helper.ts | 3 +- src/scale/Interval.ts | 279 ++++++------------ src/scale/Log.ts | 165 ++++------- src/scale/Ordinal.ts | 8 +- src/scale/Scale.ts | 104 +++---- src/scale/Time.ts | 122 ++++---- src/scale/helper.ts | 11 +- src/scale/minorTicks.ts | 81 +++++ src/util/jitter.ts | 2 +- test/axis-align-ticks.html | 4 +- test/runTest/actions/axis-align-ticks.json | 2 +- 25 files changed, 703 insertions(+), 523 deletions(-) create mode 100644 src/coord/axisNiceTicks.ts create mode 100644 src/scale/minorTicks.ts diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index cabe1db8cb..4ba157c7e3 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -458,11 +458,7 @@ class AxisProxy { const axisModel = this.getAxisModel(); - const window = this._window; - if (!window) { - return; - } - const {percent, value} = window; + const {percent, value} = this._window; // For value axis, if min/max/scale are not set, we just use the extent obtained // by series data, which may be a little different from the extent calculated by diff --git a/src/component/timeline/SliderTimelineView.ts b/src/component/timeline/SliderTimelineView.ts index 0a547abd75..23fea2bb7a 100644 --- a/src/component/timeline/SliderTimelineView.ts +++ b/src/component/timeline/SliderTimelineView.ts @@ -23,7 +23,7 @@ import * as graphic from '../../util/graphic'; import { createTextStyle } from '../../label/labelStyle'; import * as layout from '../../util/layout'; import TimelineView from './TimelineView'; -import TimelineAxis from './TimelineAxis'; +import TimelineAxis, { TimelineAxisType } from './TimelineAxis'; import {createSymbol, normalizeSymbolOffset, normalizeSymbolSize} from '../../util/symbol'; import * as numberUtil from '../../util/number'; import GlobalModel from '../../model/Global'; @@ -35,10 +35,6 @@ import TimelineModel, { TimelineDataItemOption, TimelineCheckpointStyle } from ' import { TimelineChangePayload, TimelinePlayChangePayload } from './timelineAction'; import Model from '../../model/Model'; import { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; -import Scale from '../../scale/Scale'; -import OrdinalScale from '../../scale/Ordinal'; -import TimeScale from '../../scale/Time'; -import IntervalScale from '../../scale/Interval'; import { VectorArray } from 'zrender/src/core/vector'; import { parsePercent } from 'zrender/src/contain/text'; import { makeInner } from '../../util/model'; @@ -46,6 +42,9 @@ import { getECData } from '../../util/innerStore'; import { enableHoverEmphasis } from '../../util/states'; import { createTooltipMarkup } from '../tooltip/tooltipMarkup'; import Displayable from 'zrender/src/graphic/Displayable'; +import { createScaleByModel } from '../../coord/axisHelper'; +import { OptionAxisType } from '../../coord/axisCommonTypes'; +import { scaleCalcNiceReal } from '../../coord/axisNiceTicks'; const PI = Math.PI; @@ -338,9 +337,12 @@ class SliderTimelineView extends TimelineView { private _createAxis(layoutInfo: LayoutInfo, timelineModel: SliderTimelineModel) { const data = timelineModel.getData(); - const axisType = timelineModel.get('axisType'); + let axisType = timelineModel.get('axisType') || timelineModel.get('type') as TimelineAxisType; + if (axisType !== 'category' && axisType !== 'time') { + axisType = 'value'; + } - const scale = createScaleByModel(timelineModel, axisType); + const scale = createScaleByModel(timelineModel, axisType as OptionAxisType); // Customize scale. The `tickValue` is `dataIndex`. scale.getTicks = function () { @@ -351,7 +353,7 @@ class SliderTimelineView extends TimelineView { const dataExtent = data.getDataExtent('value'); scale.setExtent(dataExtent[0], dataExtent[1]); - scale.calcNiceTicks(); + scaleCalcNiceReal(scale, {fixMinMax: [true, true]}); const axis = new TimelineAxis('value', scale, layoutInfo.axisExtent as [number, number], axisType); axis.model = timelineModel; @@ -730,28 +732,6 @@ class SliderTimelineView extends TimelineView { } } -function createScaleByModel(model: SliderTimelineModel, axisType?: string): Scale { - axisType = axisType || model.get('type'); - if (axisType) { - switch (axisType) { - // Buildin scale - case 'category': - return new OrdinalScale({ - ordinalMeta: model.getCategories(), - extent: [Infinity, -Infinity] - }); - case 'time': - return new TimeScale({ - locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC') - }); - default: - // default to be value - return new IntervalScale(); - } - } -} - function getViewRect(model: SliderTimelineModel, api: ExtensionAPI) { return layout.getLayoutRect( diff --git a/src/component/timeline/TimelineAxis.ts b/src/component/timeline/TimelineAxis.ts index e0f21156ae..59e9284c53 100644 --- a/src/component/timeline/TimelineAxis.ts +++ b/src/component/timeline/TimelineAxis.ts @@ -23,12 +23,14 @@ import TimelineModel from './TimelineModel'; import { LabelOption } from '../../util/types'; import Model from '../../model/Model'; +export type TimelineAxisType = 'category' | 'time' | 'value'; + /** * Extend axis 2d */ class TimelineAxis extends Axis { - type: 'category' | 'time' | 'value'; + type: TimelineAxisType; // @ts-ignore model: TimelineModel; @@ -37,7 +39,7 @@ class TimelineAxis extends Axis { dim: string, scale: Scale, coordExtent: [number, number], - axisType: 'category' | 'time' | 'value' + axisType: TimelineAxisType ) { super(dim, scale, coordExtent); this.type = axisType || 'value'; diff --git a/src/component/timeline/TimelineModel.ts b/src/component/timeline/TimelineModel.ts index d366589d99..ad6de3166b 100644 --- a/src/component/timeline/TimelineModel.ts +++ b/src/component/timeline/TimelineModel.ts @@ -292,10 +292,6 @@ class TimelineModel extends ComponentModel { return this._data; } - /** - * @public - * @return {Array.} categoreis - */ getCategories() { if (this.get('axisType') === 'category') { return this._names.slice(); diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index f392c74eb9..fc871006af 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -113,7 +113,7 @@ export function alignScaleTicks( // At least one nice segment is present, and not-nice segments are only present on // the start and/or the end. // In this case, ticks corresponding to nice segments are made "nice". - const alignToInterval = alignToScaleLinear.getInterval(); + const alignToInterval = alignToScaleLinear.getConfig().interval; t0 = ( 1 - (alignToTicks[0].value - alignToExpNiceTicks[0].value) / alignToInterval ) % 1; @@ -282,23 +282,28 @@ export function alignScaleTicks( const currIntervalCount = mathRound((maxNice - minNice) / interval); if (currIntervalCount <= alignToNiceSegCount) { const moreCount = alignToNiceSegCount - currIntervalCount; - // Consider cases that negative series data do not make sense (or vice versa), users can - // simply specify `xxxAxis.min/max: 0` to achieve that. But we still optimize it for some - // common default cases whenever possible, especially when ec option `xxxAxis.scale: false` - // (the default), it is usually unexpected if negative (or positive) ticks are introduced. + // Consider cases that negative tick do not make sense (or vice versa), users can simply + // specify `xxxAxis.min/max: 0` to avoid negative. But we still automatically handle it + // for some common cases whenever possible: + // - When ec option is `xxxAxis.scale: false` (the default), it is usually unexpected if + // negative (or positive) ticks are introduced. + // - In LogScale, series data are usually either all > 1 or all < 1, rather than both, + // that is, logarithm result is typically either all positive or all negative. let moreCountPair: number[]; - const needCrossZero = targetExtentInfo.needCrossZero; - if (needCrossZero && targetExtent[0] === 0) { + const mayEnhanceZero = targetExtentInfo.needCrossZero || isTargetLogScale; + // `bounds < 0` or `bounds > 0` may require more complex handling, so we only auto handle + // `bounds === 0`. + if (mayEnhanceZero && targetExtent[0] === 0) { // 0 has been included in extent and all positive. moreCountPair = [0, moreCount]; } - else if (needCrossZero && targetExtent[1] === 0) { + else if (mayEnhanceZero && targetExtent[1] === 0) { // 0 has been included in extent and all negative. moreCountPair = [moreCount, 0]; } else { - // Try to arrange tick in the middle as possible corresponding to the given `alignTo` - // ticks, which is especially preferable in `LogScale`. + // Try to center ticks in axis space whenever possible, which is especially preferable + // in `LogScale`. const lessHalfCount = mathFloor(moreCount / 2); moreCountPair = moreCount % 2 === 0 ? [lessHalfCount, lessHalfCount] : (min + max) < (targetExtent[0] + targetExtent[1]) ? [lessHalfCount, lessHalfCount + 1] @@ -325,9 +330,9 @@ export function alignScaleTicks( ] : []; - // NOTE: Must in setExtent -> setInterval order. + // NOTE: Must in setExtent -> setConfigs order. targetScaleLinear.setExtent(min, max); - targetScaleLinear.setInterval({ + targetScaleLinear.setConfig({ // Even in LogScale, `interval` should not be in log space. interval, intervalCount, diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 944ad59491..bd4df43911 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -34,6 +34,9 @@ import type { PrimaryTimeUnit } from '../util/time'; export const AXIS_TYPES = {value: 1, category: 1, time: 1, log: 1} as const; export type OptionAxisType = keyof typeof AXIS_TYPES; +// `scale/Ordinal` | `scale/Interval` | `scale/Log` | `scale/Time` +export type AxisScaleType = 'ordinal' | 'interval' | 'log' | 'time'; + export interface AxisBaseOptionCommon extends ComponentOption, AnimationOptionMixin { type?: OptionAxisType; diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 4fa38b5ae0..b0771dbe5b 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -41,6 +41,7 @@ import { AxisLabelCategoryFormatter, AxisLabelValueFormatter, AxisLabelFormatterExtraParams, + OptionAxisType, } from './axisCommonTypes'; import CartesianAxisModel from './cartesian/AxisModel'; import SeriesData from '../data/SeriesData'; @@ -50,7 +51,8 @@ import { ensureScaleRawExtentInfo, ScaleRawExtentResult } from './scaleRawExtent import { parseTimeAxisLabelFormatter } from '../util/time'; import { getScaleBreakHelper } from '../scale/break'; import { error } from '../util/log'; -import { isIntervalScale, isTimeScale } from '../scale/helper'; +import { isTimeScale } from '../scale/helper'; +import { AxisModelExtendedInCreator } from './axisModelCreator'; type BarWidthAndOffset = ReturnType; @@ -157,43 +159,22 @@ function adjustScaleForOverflow( return {min: min, max: max}; } -export function niceScaleExtent( - scale: Scale, - inModel: AxisBaseModel, - // Typically: data extent from all series on this axis, which can be obtained by - // `scale.unionExtentFromData(...); scale.getExtent();`. - dataExtent: number[], -): void { - const model = inModel as AxisBaseModel; - const extentInfo = adoptScaleExtentOptionAndPrepare(scale, model, dataExtent); - - const isInterval = isIntervalScale(scale); - const isIntervalOrTime = isInterval || isTimeScale(scale); - - scale.setBreaksFromOption(retrieveAxisBreaksOption(model)); - scale.setExtent(extentInfo.min, extentInfo.max); - scale.calcNiceExtent({ - splitNumber: model.get('splitNumber'), - fixMinMax: [extentInfo.minFixed, extentInfo.maxFixed], - minInterval: isIntervalOrTime ? model.get('minInterval') : null, - maxInterval: isIntervalOrTime ? model.get('maxInterval') : null - }); - - // If some one specified the min, max. And the default calculated interval - // is not good enough. He can specify the interval. It is often appeared - // in angle axis with angle 0 - 360. Interval calculated in interval scale is hard - // to be 60. - // In `xxxAxis.type: 'log'`, ec option `xxxAxis.interval` requires a logarithm-applied - // value rather than a value in the raw scale. - const interval = model.get('interval'); - if (interval != null && (scale as IntervalScale).setInterval) { - (scale as IntervalScale).setInterval({interval}); - } -} - -export function createScaleByModel(model: AxisBaseModel): Scale { - const axisType = model.get('type'); - switch (axisType) { +export function createScaleByModel( + model: + Model< + // Expect `Pick`, + // but be lenient for user's invalid input. + {type?: string} + & Pick + > + & Partial>, + axisType?: OptionAxisType +): Scale { + const type = axisType || model.get('type'); + switch (type) { case 'category': return new OrdinalScale({ ordinalMeta: model.getOrdinalMeta @@ -208,10 +189,10 @@ export function createScaleByModel(model: AxisBaseModel): Scale { }); case 'log': // See also #3749 - return new LogScale((model as AxisBaseModel).get('logBase')); + return new LogScale(model.get('logBase')); default: // case 'value'/'interval', or others. - return new (Scale.getClass(axisType) || IntervalScale)(); + return new (Scale.getClass(type) || IntervalScale)(); } } diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts new file mode 100644 index 0000000000..ce1f8bfe59 --- /dev/null +++ b/src/coord/axisNiceTicks.ts @@ -0,0 +1,272 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { assert, noop } from 'zrender/src/core/util'; +import { + ensureValidSplitNumber, getIntervalPrecision, + intervalScaleEnsureValidExtent, + isIntervalScale, isTimeScale +} from '../scale/helper'; +import IntervalScale from '../scale/Interval'; +import { getPrecision, mathCeil, mathFloor, mathMax, nice, quantity, round } from '../util/number'; +import type { AxisBaseModel } from './AxisBaseModel'; +import type { AxisScaleType, LogAxisBaseOption } from './axisCommonTypes'; +import { adoptScaleExtentOptionAndPrepare, retrieveAxisBreaksOption } from './axisHelper'; +import { timeScaleCalcNice } from '../scale/Time'; +import type LogScale from '../scale/Log'; +import { NullUndefined } from '../util/types'; +import Scale from '../scale/Scale'; + + +// ------ START: LinearIntervalScaleStub Nice ------ + +type LinearIntervalScaleStubCalcNiceTicks = ( + scale: IntervalScale, + opt: Pick +) => { + intervalPrecision: number; + interval: number; + niceExtent: number[]; +}; + +type LinearIntervalScaleStubCalcExtentPrecision = ( + oldExtent: number[], + newExtent: number[], + opt: Pick +) => ( + (number | NullUndefined)[] | NullUndefined +); + +function linearIntervalScaleStubCalcNice( + linearIntervalScaleStub: IntervalScale, + opt: ScaleCalcNiceMethodOpt, + opt2: { + calcNiceTicks: LinearIntervalScaleStubCalcNiceTicks; + calcExtentPrecision: LinearIntervalScaleStubCalcExtentPrecision; + } +): void { + // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. + + const fixMinMax = opt.fixMinMax || []; + const oldExtent = linearIntervalScaleStub.getExtent(); + + let extent = intervalScaleEnsureValidExtent(oldExtent.slice(), fixMinMax); + + linearIntervalScaleStub.setExtent(extent[0], extent[1]); + extent = linearIntervalScaleStub.getExtent(); + + const { + interval, + intervalPrecision, + niceExtent, + } = opt2.calcNiceTicks(linearIntervalScaleStub, opt); + + if (!fixMinMax[0]) { + extent[0] = round(mathFloor(extent[0] / interval) * interval, intervalPrecision); + } + if (!fixMinMax[1]) { + extent[1] = round(mathCeil(extent[1] / interval) * interval, intervalPrecision); + } + + const extentPrecision = opt2.calcExtentPrecision(oldExtent, extent, opt); + + linearIntervalScaleStub.setExtent(extent[0], extent[1]); + linearIntervalScaleStub.setConfig({ + interval, + intervalPrecision, + niceExtent, + extentPrecision + }); +} + +// ------ END: LinearIntervalScaleStub Nice ------ + + +// ------ START: IntervalScale Nice ------ + +const intervalScaleCalcNiceTicks: LinearIntervalScaleStubCalcNiceTicks = function (scale, opt) { + const splitNumber = ensureValidSplitNumber(opt.splitNumber, 5); + const extent = scale.getExtent(); + const span = scale.getBreaksElapsedExtentSpan(); + + if (__DEV__) { + assert(isFinite(span) && span > 0); // It should be ensured by `intervalScaleEnsureValidExtent`. + } + + const minInterval = opt.minInterval; + const maxInterval = opt.maxInterval; + + let interval = nice(span / splitNumber, true); + if (minInterval != null && interval < minInterval) { + interval = minInterval; + } + if (maxInterval != null && interval > maxInterval) { + interval = maxInterval; + } + const intervalPrecision = getIntervalPrecision(interval); + // Nice extent inside original extent + const niceExtent = [ + round(mathCeil(extent[0] / interval) * interval, intervalPrecision), + round(mathFloor(extent[1] / interval) * interval, intervalPrecision) + ]; + + return {interval, intervalPrecision, niceExtent}; +}; + +const intervalScaleCalcNice: ScaleCalcNiceMethod = function ( + scale: IntervalScale, opt +) { + linearIntervalScaleStubCalcNice(scale, opt, { + calcNiceTicks: intervalScaleCalcNiceTicks, + calcExtentPrecision: noop as unknown as LinearIntervalScaleStubCalcExtentPrecision, + }); +}; + +// ------ END: IntervalScale Nice ------ + + +// ------ START: LogScale Nice ------ + +const logScaleCalcNiceTicks: LinearIntervalScaleStubCalcNiceTicks = function ( + linearStub: IntervalScale, opt +) { + // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. + + const splitNumber = ensureValidSplitNumber(opt.splitNumber, 10); + const extent = linearStub.getExtent(); + const span = linearStub.getBreaksElapsedExtentSpan(); + + if (__DEV__) { + assert(isFinite(span) && span > 0); // It should be ensured by `intervalScaleEnsureValidExtent`. + } + + // Interval should be integer + let interval = mathMax(quantity(span), 1); + + const err = splitNumber / span * interval; + + // Filter ticks to get closer to the desired count. + if (err <= 0.5) { + // TODO: support other bases other than 10? + interval *= 10; + } + + const intervalPrecision = getIntervalPrecision(interval); + const niceExtent = [ + round(mathCeil(extent[0] / interval) * interval, intervalPrecision), + round(mathFloor(extent[1] / interval) * interval, intervalPrecision) + ] as [number, number]; + + return {intervalPrecision, interval, niceExtent}; +}; + +const logScaleCalcExtentPrecision: LinearIntervalScaleStubCalcExtentPrecision = function ( + oldExtent, newExtent, opt +) { + return [ + (opt.fixMinMax && opt.fixMinMax[0] && oldExtent[0] === newExtent[0]) + ? getPrecision(newExtent[0]) : null, + (opt.fixMinMax && opt.fixMinMax[1] && oldExtent[1] === newExtent[1]) + ? getPrecision(newExtent[1]) : null + ]; +}; + +const logScaleCalcNice: ScaleCalcNiceMethod = function (scale: LogScale, opt): void { + // NOTE: Calculate nice only on linearStub of LogScale. + linearIntervalScaleStubCalcNice(scale.linearStub, opt, { + calcNiceTicks: logScaleCalcNiceTicks, + calcExtentPrecision: logScaleCalcExtentPrecision, + }); +}; + +// ------ END: LogScale Nice ------ + + +// ------ START: scaleCalcNice Entry ------ + +export type ScaleCalcNiceMethod = ( + scale: ScaleForCalcNice, + opt: ScaleCalcNiceMethodOpt +) => void; + +type ScaleForCalcNice = Pick< + Scale, + 'type' | 'setExtent' | 'getExtent' | 'getBreaksElapsedExtentSpan' +>; + +type ScaleCalcNiceMethodOpt = { + splitNumber?: number; + minInterval?: number; + maxInterval?: number; + // `[fixMin, fixMax]`. If `true`, the original `extent[0]`/`extent[1]` + // will not be modified, except for an invalid extent. + fixMinMax?: boolean[]; +}; + +export function scaleCalcNice( + scale: Scale, + // scale: Scale, + inModel: AxisBaseModel, + // Typically: data extent from all series on this axis, which can be obtained by + // `scale.unionExtentFromData(...); scale.getExtent();`. + dataExtent: number[], +): void { + const model = inModel as AxisBaseModel; + const extentInfo = adoptScaleExtentOptionAndPrepare(scale, model, dataExtent); + + const isInterval = isIntervalScale(scale); + const isIntervalOrTime = isInterval || isTimeScale(scale); + + scale.setBreaksFromOption(retrieveAxisBreaksOption(model)); + scale.setExtent(extentInfo.min, extentInfo.max); + + scaleCalcNiceReal(scale, { + splitNumber: model.get('splitNumber'), + fixMinMax: [extentInfo.minFixed, extentInfo.maxFixed], + minInterval: isIntervalOrTime ? model.get('minInterval') : null, + maxInterval: isIntervalOrTime ? model.get('maxInterval') : null + }); + + // If some one specified the min, max. And the default calculated interval + // is not good enough. He can specify the interval. It is often appeared + // in angle axis with angle 0 - 360. Interval calculated in interval scale is hard + // to be 60. + // In `xxxAxis.type: 'log'`, ec option `xxxAxis.interval` requires a logarithm-applied + // value rather than a value in the raw scale. + const interval = model.get('interval'); + if (interval != null && (scale as IntervalScale).setConfig) { + (scale as IntervalScale).setConfig({interval}); + } +} + +export function scaleCalcNiceReal( + scale: ScaleForCalcNice, + opt: ScaleCalcNiceMethodOpt +): void { + scaleCalcNiceMethods[scale.type](scale, opt); +} + +const scaleCalcNiceMethods: Record = { + interval: intervalScaleCalcNice, + log: logScaleCalcNice, + time: timeScaleCalcNice, + ordinal: noop, +}; + +// ------ END: scaleCalcNice Entry ------ diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 2b695c3dfe..3d7b067c5c 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -28,7 +28,6 @@ import {BoxLayoutReferenceResult, createBoxLayoutReference, getLayoutRect, Layou import { createScaleByModel, ifAxisCrossZero, - niceScaleExtent, getDataDimensionsOnAxis, isNameLocationCenter, shouldAxisShow, @@ -71,6 +70,7 @@ import { error, log } from '../../util/log'; import { AxisTickLabelComputingKind } from '../axisTickLabelBuilder'; import { injectCoordSysByOption } from '../../core/CoordinateSystem'; import { mathMax, parsePositionSizeOption } from '../../util/number'; +import { scaleCalcNice } from '../axisNiceTicks'; type Cartesian2DDimensionName = 'x' | 'y'; @@ -135,12 +135,12 @@ class Grid implements CoordinateSystemMaster { axisNeedsAlign.push(axis); } else { - niceScaleExtent(axis.scale, axis.model, axis.scale.getExtent()); + scaleCalcNice(axis.scale, axis.model, axis.scale.getExtent()); } }; each(axisNeedsAlign, axis => { if (incapableOfAlignNeedFallback(axis, axis.alignTo as Axis2D)) { - niceScaleExtent(axis.scale, axis.model, axis.scale.getExtent()); + scaleCalcNice(axis.scale, axis.model, axis.scale.getExtent()); } else { alignScaleTicks( diff --git a/src/coord/cartesian/defaultAxisExtentFromData.ts b/src/coord/cartesian/defaultAxisExtentFromData.ts index 76e0cdac97..a2c78eeebf 100644 --- a/src/coord/cartesian/defaultAxisExtentFromData.ts +++ b/src/coord/cartesian/defaultAxisExtentFromData.ts @@ -204,7 +204,7 @@ function calculateFilteredExtent( if (singleCondDim && singleTarDim) { for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { const condVal = data.get(singleCondDim, dataIdx) as number; - if (condAxis.scale.isInExtentRange(condVal)) { + if (condAxis.scale.isInExtent(condVal)) { unionExtent(tarDimExtents[0], data.get(singleTarDim, dataIdx) as number); } } @@ -213,7 +213,7 @@ function calculateFilteredExtent( for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { for (let j = 0; j < condDimsLen; j++) { const condVal = data.get(condDims[j], dataIdx) as number; - if (condAxis.scale.isInExtentRange(condVal)) { + if (condAxis.scale.isInExtent(condVal)) { for (let k = 0; k < tarDimsLen; k++) { unionExtent(tarDimExtents[k], data.get(tarDims[k], dataIdx) as number); } diff --git a/src/coord/parallel/Parallel.ts b/src/coord/parallel/Parallel.ts index 38ad006caa..48a7a3aa82 100644 --- a/src/coord/parallel/Parallel.ts +++ b/src/coord/parallel/Parallel.ts @@ -40,6 +40,7 @@ import ParallelAxisModel, { ParallelActiveState } from './AxisModel'; import SeriesData from '../../data/SeriesData'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; +import { scaleCalcNice } from '../axisNiceTicks'; interface ParallelCoordinateSystemLayoutInfo { @@ -184,7 +185,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { // do after all series processed each(this.dimensions, function (dim) { const axis = this._axesMap.get(dim); - axisHelper.niceScaleExtent(axis.scale, axis.model, axis.scale.getExtent()); + scaleCalcNice(axis.scale, axis.model, axis.scale.getExtent()); }, this); } diff --git a/src/coord/polar/polarCreator.ts b/src/coord/polar/polarCreator.ts index 8a7515896d..5a5a7e53a3 100644 --- a/src/coord/polar/polarCreator.ts +++ b/src/coord/polar/polarCreator.ts @@ -24,7 +24,6 @@ import Polar, { polarDimensions } from './Polar'; import {parsePercent} from '../../util/number'; import { createScaleByModel, - niceScaleExtent, getDataDimensionsOnAxis } from '../../coord/axisHelper'; @@ -41,6 +40,7 @@ import { SINGLE_REFERRING } from '../../util/model'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; import { createBoxLayoutReference } from '../../util/layout'; +import { scaleCalcNice } from '../axisNiceTicks'; /** * Resize method bound to the polar @@ -99,8 +99,8 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) } }); - niceScaleExtent(angleScale, angleAxis.model, angleScale.getExtent()); - niceScaleExtent(radiusScale, radiusAxis.model, radiusScale.getExtent()); + scaleCalcNice(angleScale, angleAxis.model, angleScale.getExtent()); + scaleCalcNice(radiusScale, radiusAxis.model, radiusScale.getExtent()); // Fix extent of category angle axis if (angleAxis.type === 'category' && !angleAxis.onBand) { diff --git a/src/coord/radar/Radar.ts b/src/coord/radar/Radar.ts index 02d0465854..46360af7c8 100644 --- a/src/coord/radar/Radar.ts +++ b/src/coord/radar/Radar.ts @@ -174,7 +174,7 @@ class Radar implements CoordinateSystem, CoordinateSystemMaster { const splitNumber = radarModel.get('splitNumber'); const dummyScale = new IntervalScale(); dummyScale.setExtent(0, splitNumber); - dummyScale.setInterval({interval: 1}); + dummyScale.setConfig({interval: 1}); // Force all the axis fixing the maxSplitNumber. each(indicatorAxes, function (indicatorAxis, idx) { alignScaleTicks( diff --git a/src/coord/single/Single.ts b/src/coord/single/Single.ts index 5610fe7d25..eb1e2f50b3 100644 --- a/src/coord/single/Single.ts +++ b/src/coord/single/Single.ts @@ -34,6 +34,7 @@ import { ParsedModelFinder, ParsedModelFinderKnown } from '../../util/model'; import { ScaleDataValue } from '../../util/types'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; +import { scaleCalcNice } from '../axisNiceTicks'; export const singleDimensions = ['single']; /** @@ -104,7 +105,7 @@ class Single implements CoordinateSystem, CoordinateSystemMaster { each(data.mapDimensionsAll(this.dimension), function (dim) { scale.unionExtentFromData(data, dim); }); - axisHelper.niceScaleExtent(scale, axis.model, scale.getExtent()); + scaleCalcNice(scale, axis.model, scale.getExtent()); } }, this); } diff --git a/src/export/api/helper.ts b/src/export/api/helper.ts index 7ef631e78e..803c0f6740 100644 --- a/src/export/api/helper.ts +++ b/src/export/api/helper.ts @@ -38,6 +38,7 @@ import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { getECData } from '../../util/innerStore'; import { createTextStyle as innerCreateTextStyle } from '../../label/labelStyle'; import { DisplayState, TextCommonOption } from '../../util/types'; +import { scaleCalcNice } from '../../coord/axisNiceTicks'; /** * Create a multi dimension List structure from seriesModel. @@ -96,7 +97,7 @@ export function createScale(dataExtent: number[], option: object | AxisBaseModel const scale = axisHelper.createScaleByModel(axisModel as AxisBaseModel); scale.setExtent(dataExtent[0], dataExtent[1]); - axisHelper.niceScaleExtent(scale, axisModel as AxisBaseModel, scale.getExtent()); + scaleCalcNice(scale, axisModel as AxisBaseModel, scale.getExtent()); return scale; } diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index a2b7cf9a3f..78babe18be 100644 --- a/src/scale/Interval.ts +++ b/src/scale/Interval.ts @@ -18,23 +18,34 @@ */ -import {round, mathRound, mathMin, getPrecision, mathCeil, mathFloor} from '../util/number'; +import {round, mathRound, mathMin, getPrecision} from '../util/number'; import {addCommas} from '../util/format'; import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; import * as helper from './helper'; -import {ScaleTick, ParsedAxisBreakList, ScaleDataValue, NullUndefined} from '../util/types'; +import {ScaleTick, ScaleDataValue, NullUndefined} from '../util/types'; import { getScaleBreakHelper } from './break'; -import { assert, retrieve2 } from 'zrender/src/core/util'; +import { assert, clone } from 'zrender/src/core/util'; +import { getMinorTicks } from './minorTicks'; -class IntervalScale extends Scale { - static type = 'interval'; - type = 'interval'; +type IntervalScaleConfig = { + interval: IntervalScaleConfigParsed['interval']; + intervalPrecision?: IntervalScaleConfigParsed['intervalPrecision'] | NullUndefined; + extentPrecision?: IntervalScaleConfigParsed['extentPrecision'] | NullUndefined; + intervalCount?: IntervalScaleConfigParsed['intervalCount'] | NullUndefined; + niceExtent?: IntervalScaleConfigParsed['niceExtent'] | NullUndefined; +}; - // Step is calculated in adjustExtent. - protected _interval: number = 0; - protected _intervalPrecision: number = 2; - protected _extentPrecision: number[] = []; +type IntervalScaleConfigParsed = { + /** + * Step of ticks. + */ + interval: number; + intervalPrecision: number; + /** + * Precisions of `_extent[0]` and `_extent[1]`. + */ + extentPrecision: (number | NullUndefined)[]; /** * `_intervalCount` effectively specifies the number of "nice segments". This is for special cases, * such as `alignTicks: true` and min max are fixed. In this case, `_interval` may be specified with @@ -46,7 +57,7 @@ class IntervalScale e * and `0` means only one nice tick (e.g., `_extent: [5, 5.8], _interval: 1`). * @see setInterval */ - private _intervalCount: number | NullUndefined = undefined; + intervalCount: number | NullUndefined; /** * Should ensure: * `_extent[0] <= _niceExtent[0] && _niceExtent[1] <= _extent[1]` @@ -58,9 +69,29 @@ class IntervalScale e * e.g., `_extent: [5, 5.8]` with interval `1` will get `_niceExtent: [5, 5]`. * @see setInterval */ - protected _niceExtent: [number, number]; + niceExtent: number[] | NullUndefined; +}; +class IntervalScale extends Scale { + + static type = 'interval'; + type = 'interval' as const; + + private _cfg: IntervalScaleConfigParsed; + + + constructor(setting?: SETTING) { + super(setting); + this._cfg = { + interval: 0, + intervalPrecision: 2, + extentPrecision: [], + intervalCount: undefined, + niceExtent: undefined, + }; + } + parse(val: ScaleDataValue): number { // `Scale#parse` (and its overrids) are typically applied at the axis values input // in echarts option. e.g., `axis.min/max`, `dataZoom.min/max`, etc. @@ -100,63 +131,58 @@ class IntervalScale e return this._calculator.scale(val, this._extent); } - getInterval(): number { - return this._interval; + getConfig(): IntervalScaleConfigParsed { + return clone(this._cfg); } /** * @final override is DISALLOWED. */ - setInterval({interval, intervalCount, intervalPrecision, extentPrecision, niceExtent}: { - interval?: number | NullUndefined; - // See comments of `_intervalCount`. - intervalCount?: number | NullUndefined; - intervalPrecision?: number | NullUndefined; - extentPrecision?: number[] | NullUndefined; - niceExtent?: number[]; - }): void { + setConfig(cfg: IntervalScaleConfig): void { const extent = this._extent; if (__DEV__) { - assert(interval != null); - if (intervalCount != null) { + assert(cfg.interval != null); + if (cfg.intervalCount != null) { assert( - intervalCount >= -1 - && intervalPrecision != null + cfg.intervalCount >= -1 + && cfg.intervalPrecision != null // Do not support intervalCount on axis break currently. && !this.hasBreaks() ); } - if (niceExtent != null) { - assert(isFinite(niceExtent[0]) && isFinite(niceExtent[1])); - assert(extent[0] <= niceExtent[0] && niceExtent[1] <= extent[1]); - assert(round(niceExtent[0] - niceExtent[1], getPrecision(interval)) <= interval); + if (cfg.niceExtent != null) { + assert(isFinite(cfg.niceExtent[0]) && isFinite(cfg.niceExtent[1])); + assert(extent[0] <= cfg.niceExtent[0] && cfg.niceExtent[1] <= extent[1]); + assert(round(cfg.niceExtent[0] - cfg.niceExtent[1], getPrecision(cfg.interval)) <= cfg.interval); } } - // Set or clear - this._niceExtent = - niceExtent != null ? niceExtent.slice() as [number, number] + // Reset all. + this._cfg = cfg = clone(cfg) as IntervalScaleConfigParsed; + if (cfg.niceExtent == null) { // Dropped the auto calculated niceExtent and use user-set extent. // We assume users want to set both interval and extent to get a better result. - : extent.slice() as [number, number]; - this._interval = interval; - this._intervalCount = intervalCount; - this._intervalPrecision = retrieve2(intervalPrecision, helper.getIntervalPrecision(interval)); - this._extentPrecision = extentPrecision || []; + cfg.niceExtent = extent.slice() as [number, number]; + } + if (cfg.intervalPrecision == null) { + cfg.intervalPrecision = helper.getIntervalPrecision(cfg.interval); + } + cfg.extentPrecision = cfg.extentPrecision || []; } /** * In ascending order. * - * @override + * @final override is DISALLOWED. */ getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { opt = opt || {}; - const interval = this._interval; + const cfg = this._cfg; + const interval = cfg.interval; const extent = this._extent; - const niceTickExtent = this._niceExtent; - const intervalPrecision = this._intervalPrecision; + const niceExtent = cfg.niceExtent; + const intervalPrecision = cfg.intervalPrecision; const scaleBreakHelper = getScaleBreakHelper(); const ticks = [] as ScaleTick[]; @@ -170,15 +196,19 @@ class IntervalScale e return ticks; } + if (__DEV__) { + assert(niceExtent != null); + } + // [CAVEAT]: If changing this logic, must sync it to `axisAlignTicks.ts`. // Consider this case: using dataZoom toolbox, zoom and zoom. const safeLimit = 10000; - if (extent[0] < niceTickExtent[0]) { + if (extent[0] < niceExtent[0]) { if (opt.expandToNicedExtent) { ticks.push({ - value: round(niceTickExtent[0] - interval, intervalPrecision) + value: round(niceExtent[0] - interval, intervalPrecision) }); } else { @@ -192,9 +222,9 @@ class IntervalScale e return mathRound((targetTick - tickVal) / interval); }; - const intervalCount = this._intervalCount; + const intervalCount = cfg.intervalCount; for ( - let tick = niceTickExtent[0], niceTickIdx = 0; + let tick = niceExtent[0], niceTickIdx = 0; ; niceTickIdx++ ) { @@ -203,7 +233,7 @@ class IntervalScale e // Consider case `_extent: [5, 5.8], _niceExtent: [5, 5], interval: 1`, // `_intervalCount` makes sense iff `0`. if (intervalCount == null) { - if (tick > niceTickExtent[1] || !isFinite(tick) || !isFinite(niceTickExtent[1])) { + if (tick > niceExtent[1] || !isFinite(tick) || !isFinite(niceExtent[1])) { break; } } @@ -212,10 +242,10 @@ class IntervalScale e break; } // Consider cumulative error, especially caused by rounding, the last nice - // `tick` may be less than or greater than `niceTickExtent[1]` slightly. - tick = mathMin(tick, niceTickExtent[1]); + // `tick` may be less than or greater than `niceExtent[1]` slightly. + tick = mathMin(tick, niceExtent[1]); if (niceTickIdx === intervalCount) { - tick = niceTickExtent[1]; + tick = niceExtent[1]; } } @@ -244,8 +274,8 @@ class IntervalScale e } // Consider this case: the last item of ticks is smaller - // than niceTickExtent[1] and niceTickExtent[1] === extent[1]. - const lastNiceTick = ticks.length ? ticks[ticks.length - 1].value : niceTickExtent[1]; + // than niceExtent[1] and niceExtent[1] === extent[1]. + const lastNiceTick = ticks.length ? ticks[ticks.length - 1].value : niceExtent[1]; if (extent[1] > lastNiceTick) { if (opt.expandToNicedExtent) { ticks.push({ @@ -265,7 +295,7 @@ class IntervalScale e ticks, this._brkCtx!.breaks, item => item.value, - this._interval, + cfg.interval, this._extent ); } @@ -276,71 +306,24 @@ class IntervalScale e return ticks; } + /** + * @final override is DISALLOWED. + */ getMinorTicks(splitNumber: number): number[][] { - const ticks = this.getTicks({ - expandToNicedExtent: true, - }); - // NOTE: In log-scale, do not support minor ticks when breaks exist. - // because currently log-scale minor ticks is calculated based on raw values - // rather than log-transformed value, due to an odd effect when breaks exist. - const minorTicks = []; - const extent = this.getExtent(); - - for (let i = 1; i < ticks.length; i++) { - const nextTick = ticks[i]; - const prevTick = ticks[i - 1]; - - if (prevTick.break || nextTick.break) { - // Do not build minor ticks to the adjacent ticks to breaks ticks, - // since the interval might be irregular. - continue; - } - - let count = 0; - const minorTicksGroup = []; - const interval = nextTick.value - prevTick.value; - const minorInterval = interval / splitNumber; - const minorIntervalPrecision = helper.getIntervalPrecision(minorInterval); - - while (count < splitNumber - 1) { - const minorTick = round(prevTick.value + (count + 1) * minorInterval, minorIntervalPrecision); - - // For the first and last interval. The count may be less than splitNumber. - if (minorTick > extent[0] && minorTick < extent[1]) { - minorTicksGroup.push(minorTick); - } - count++; - } - - const scaleBreakHelper = getScaleBreakHelper(); - scaleBreakHelper && scaleBreakHelper.pruneTicksByBreak( - 'auto', - minorTicksGroup, - this._getNonTransBreaks(), - value => value, - this._interval, - extent - ); - minorTicks.push(minorTicksGroup); - } - - return minorTicks; - } - - protected _getNonTransBreaks(): ParsedAxisBreakList { - return this._brkCtx ? this._brkCtx.breaks : []; + return getMinorTicks( + this, + splitNumber, + this.innerGetBreaks(), + this._cfg.interval + ); } /** - * @param opt.precision If 'auto', use nice presision. - * @param opt.pad returns 1.50 but not 1.5 if precision is 2. + * @final override is DISALLOWED. */ getLabel( data: ScaleTick, - opt?: { - precision?: 'auto' | number, - pad?: boolean - } + opt?: helper.IntervalScaleGetLabelOpt ): string { if (data == null) { return ''; @@ -353,7 +336,7 @@ class IntervalScale e } else if (precision === 'auto') { // Should be more precise then tick. - precision = this._intervalPrecision; + precision = this._cfg.intervalPrecision; } // (1) If `precision` is set, 12.005 should be display as '12.00500'. @@ -363,78 +346,6 @@ class IntervalScale e return addCommas(dataNum); } - /** - * FIXME: refactor - disallow override, use composition instead. - * - * The override of `calcNiceTicks` should ensure these members are provided: - * this._intervalPrecision - * this._interval - * - * @param splitNumber By default `5`. - */ - calcNiceTicks(splitNumber?: number, minInterval?: number, maxInterval?: number): void { - splitNumber = helper.ensureValidSplitNumber(splitNumber, 5); - let extent = this._extent.slice() as [number, number]; - let span = this._getExtentSpanWithBreaks(); - - if (!isFinite(span)) { - // FIXME: Check and refactor this branch -- this return should never happen; - // otherwise the subsequent logic may be incorrect. - return; - } - - // User may set axis min 0 and data are all negative - // FIXME If it needs to reverse ? - if (span < 0) { - span = -span; - extent.reverse(); - this._innerSetExtent(extent[0], extent[1]); - extent = this._extent.slice() as [number, number]; - } - - const result = helper.intervalScaleNiceTicks( - extent, span, splitNumber, minInterval, maxInterval - ); - - this._intervalPrecision = result.intervalPrecision; - this._interval = result.interval; - this._niceExtent = result.niceTickExtent; - } - - /** - * FIXME: refactor - disallow override for readability; use composition instead. - * `calcNiceExtent` and `alignScaleTicks` both implement tick arrangement (for - * two scenarios), but they are implemented in two different code styles. - */ - calcNiceExtent(opt: { - splitNumber: number, // By default 5. - // Do not modify the original extent[0]/extent[1] except for an invalid extent. - fixMinMax?: boolean[], // [fixMin, fixMax] - minInterval?: number, - maxInterval?: number - }): void { - const fixMinMax = opt.fixMinMax || []; - - let extent = helper.intervalScaleEnsureValidExtent(this._extent, fixMinMax); - - this._innerSetExtent(extent[0], extent[1]); - extent = this._extent.slice() as [number, number]; - - this.calcNiceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval); - const interval = this._interval; - const intervalPrecition = this._intervalPrecision; - - if (!fixMinMax[0]) { - extent[0] = round(mathFloor(extent[0] / interval) * interval, intervalPrecition); - } - if (!fixMinMax[1]) { - extent[1] = round(mathCeil(extent[1] / interval) * interval, intervalPrecition); - } - this._innerSetExtent(extent[0], extent[1]); - - // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. - } - } Scale.registerClass(IntervalScale); diff --git a/src/scale/Log.ts b/src/scale/Log.ts index ea32d80054..5481003bf7 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -20,80 +20,58 @@ import * as zrUtil from 'zrender/src/core/util'; import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; import { - mathFloor, mathCeil, mathPow, mathLog, - round, quantity, getPrecision, - mathMax, + mathPow, mathLog, } from '../util/number'; // Use some method of IntervalScale import IntervalScale from './Interval'; import { - DimensionLoose, DimensionName, ParsedAxisBreakList, AxisBreakOption, + DimensionLoose, DimensionName, AxisBreakOption, ScaleTick, - NullUndefined + NullUndefined, + ScaleDataValue } from '../util/types'; import { - ensureValidSplitNumber, getIntervalPrecision, logScalePowTickPair, logScalePowTick, logScaleLogTickPair, - getExtentPrecision + getExtentPrecision, + IntervalScaleGetLabelOpt, + logScaleLogTick, } from './helper'; import SeriesData from '../data/SeriesData'; import { getScaleBreakHelper } from './break'; +import { getMinorTicks } from './minorTicks'; -const LINEAR_STUB_METHODS = [ - 'getExtent', 'getTicks', 'getInterval', - 'setExtent', 'setInterval', -] as const; - -/** - * IMPL_MEMO: - * - The supper class (`IntervalScale`) and its member fields (such as `this._extent`, - * `this._interval`, `this._niceExtent`) provides linear tick arrangement (logarithm applied). - * - `_originalScale` (`IntervalScale`) is used to save some original info - * (before logarithm applied, such as raw extent; but may be still invalid, and not sync to the - * calculated ("nice") extent). - */ -class LogScale extends IntervalScale { +class LogScale extends Scale { static type = 'log'; - readonly type = 'log'; + readonly type = 'log' as const; readonly base: number; - private _originalScale = new IntervalScale(); - - linearStub: Pick; + // `_originalScale` is used to save some original info (before logarithm + // applied, such as raw extent; but may be still invalid, and not sync + // to the calculated ("nice") extent). + private _originalScale: IntervalScale; + // `linearStub` provides linear tick arrangement (logarithm applied). + readonly linearStub: IntervalScale; constructor(logBase: number | NullUndefined, settings?: ScaleSettingDefault) { - super(settings); + super(); + this._originalScale = new IntervalScale(); + this.linearStub = new IntervalScale(settings); this.base = zrUtil.retrieve2(logBase, 10); - this._initLinearStub(); - } - - private _initLinearStub(): void { - // TODO: Refactor -- This impl is error-prone. And the use of `prototype` should be removed. - const intervalScaleProto = IntervalScale.prototype; - const logScale = this; - const stub = logScale.linearStub = {} as LogScale['linearStub']; - zrUtil.each(LINEAR_STUB_METHODS, function (methodName) { - stub[methodName] = function () { - return (intervalScaleProto[methodName] as any).apply(logScale, arguments); - }; - }); } - /** - * @param Whether expand the ticks to niced extent. - */ getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { const base = this.base; const originalScale = this._originalScale; const scaleBreakHelper = getScaleBreakHelper(); - const extent = this._extent; - const extentPrecision = this._extentPrecision; + const linearStub = this.linearStub; + const extent = linearStub.getExtent(); + const extentPrecision = linearStub.getConfig().extentPrecision; - return zrUtil.map(super.getTicks(opt || {}), function (tick) { + return zrUtil.map(linearStub.getTicks(opt || {}), function (tick) { const val = tick.value; let powVal = logScalePowTick( val, @@ -106,7 +84,7 @@ class LogScale extends IntervalScale { const brkPowResult = scaleBreakHelper.getTicksPowBreak( tick, base, - originalScale._innerGetBreaks(), + originalScale.innerGetBreaks(), extent, extentPrecision ); @@ -123,98 +101,65 @@ class LogScale extends IntervalScale { }, this); } - protected _getNonTransBreaks(): ParsedAxisBreakList { - return this._originalScale._innerGetBreaks(); + getMinorTicks(splitNumber: number): number[][] { + return getMinorTicks( + this, + splitNumber, + this._originalScale.innerGetBreaks(), + // NOTE: minor ticks are in the log scale value to visually hint users "logarithm". + this.linearStub.getConfig().interval + ); + } + + getLabel( + data: ScaleTick, + opt?: IntervalScaleGetLabelOpt + ) { + return this.linearStub.getLabel(data, opt); } setExtent(start: number, end: number): void { // [CAVEAT]: If modifying this logic, must sync to `_initLinearStub`. this._originalScale.setExtent(start, end); const loggedExtent = logScaleLogTickPair([start, end], this.base); - super.setExtent(loggedExtent[0], loggedExtent[1]); + this.linearStub.setExtent(loggedExtent[0], loggedExtent[1]); } getExtent() { - const extent = super.getExtent(); + const linearStub = this.linearStub; return logScalePowTickPair( - extent, + linearStub.getExtent(), this.base, - this._extentPrecision + linearStub.getConfig().extentPrecision ); } + isInExtent(value: number): boolean { + return this.linearStub.isInExtent(logScaleLogTick(value, this.base)); + } + unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { this._originalScale.unionExtentFromData(data, dim); const loggedOther = logScaleLogTickPair(data.getApproximateExtent(dim), this.base, true); - this._innerUnionExtent(loggedOther); - } - - /** - * Update interval and extent of intervals for nice ticks - * @param splitNumber default 10 Given approx tick number - */ - calcNiceTicks(splitNumber: number): void { - splitNumber = ensureValidSplitNumber(splitNumber, 10); - const extent = this._extent.slice() as [number, number]; - const span = this._getExtentSpanWithBreaks(); - if (!isFinite(span) || span <= 0) { - return; - } - - // Interval should be integer - let interval = mathMax(quantity(span), 1); - - const err = splitNumber / span * interval; - - // Filter ticks to get closer to the desired count. - if (err <= 0.5) { - // TODO: support other bases other than 10? - interval *= 10; - } - - const intervalPrecision = getIntervalPrecision(interval); - const niceExtent = [ - round(mathCeil(extent[0] / interval) * interval, intervalPrecision), - round(mathFloor(extent[1] / interval) * interval, intervalPrecision) - ] as [number, number]; - - this._interval = interval; - this._intervalPrecision = intervalPrecision; - this._niceExtent = niceExtent; - - // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. + this.linearStub.innerUnionExtent(loggedOther); } - calcNiceExtent(opt: { - splitNumber: number, - fixMinMax?: boolean[], - minInterval?: number, - maxInterval?: number - }): void { - const oldExtent = this._extent.slice() as [number, number]; - super.calcNiceExtent(opt); - const newExtent = this._extent; - - this._extentPrecision = [ - (opt.fixMinMax && opt.fixMinMax[0] && oldExtent[0] === newExtent[0]) - ? getPrecision(newExtent[0]) : null, - (opt.fixMinMax && opt.fixMinMax[1] && oldExtent[1] === newExtent[1]) - ? getPrecision(newExtent[1]) : null - ]; + parse(val: ScaleDataValue): number { + return this.linearStub.parse(val); } contain(val: number): boolean { val = mathLog(val) / mathLog(this.base); - return super.contain(val); + return this.linearStub.contain(val); } normalize(val: number): number { val = mathLog(val) / mathLog(this.base); - return super.normalize(val); + return this.linearStub.normalize(val); } scale(val: number): number { - val = super.scale(val); + val = this.linearStub.scale(val); return mathPow(this.base, val); } @@ -230,8 +175,8 @@ class LogScale extends IntervalScale { this.base, zrUtil.bind(this.parse, this) ); - this._originalScale._innerSetBreak(parsedOriginal); - this._innerSetBreak(parsedLogged); + this._originalScale.innerSetBreak(parsedOriginal); + this.linearStub.innerSetBreak(parsedLogged); } } diff --git a/src/scale/Ordinal.ts b/src/scale/Ordinal.ts index cf41374ffc..b7ab881a66 100644 --- a/src/scale/Ordinal.ts +++ b/src/scale/Ordinal.ts @@ -45,7 +45,7 @@ type OrdinalScaleSetting = { class OrdinalScale extends Scale { static type = 'ordinal'; - readonly type = 'ordinal'; + readonly type = 'ordinal' as const; private _ordinalMeta: OrdinalMeta; @@ -268,7 +268,7 @@ class OrdinalScale extends Scale { * @override * If value is in extent range */ - isInExtentRange(value: OrdinalNumber): boolean { + isInExtent(value: OrdinalNumber): boolean { value = this._getTickNumber(value); return this._extent[0] <= value && this._extent[1] >= value; } @@ -277,10 +277,6 @@ class OrdinalScale extends Scale { return this._ordinalMeta; } - calcNiceTicks() {} - - calcNiceExtent() {} - } Scale.registerClass(OrdinalScale); diff --git a/src/scale/Scale.ts b/src/scale/Scale.ts index f462665729..3dfe36195c 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -36,9 +36,10 @@ import { import { ScaleRawExtentInfo } from '../coord/scaleRawExtentInfo'; import { bind } from 'zrender/src/core/util'; import { ScaleBreakContext, AxisBreakParsingResult, getScaleBreakHelper, ParamPruneByBreak } from './break'; +import { AxisScaleType } from '../coord/axisCommonTypes'; export type ScaleGetTicksOpt = { - // Whether expand the ticks to niced extent. + // Whether expand the ticks to nice extent. expandToNicedExtent?: boolean; pruneByBreak?: ParamPruneByBreak; // - not specified or undefined(default): insert the breaks as items into the tick array. @@ -54,16 +55,16 @@ export type ScaleSettingDefault = Dictionary; abstract class Scale { - type: string; + type: AxisScaleType; private _setting: SETTING; - // [CAVEAT]: Should update only by `_innerSetExtent`! + // [CAVEAT]: Should update only by `setExtent`! // Make sure that extent[0] always <= extent[1]. protected _extent: [number, number]; - // FIXME: Effectively, both logorithmic scale and break scale are numeric axis transformation - // mechanisms. However, for historical reason, logorithmic scale is implemented as a subclass, + // FIXME: Effectively, both logarithmic scale and break scale are numeric axis transformation + // mechanisms. However, for historical reason, logarithmic scale is implemented as a subclass, // while break scale is implemented inside the base class `Scale`. If more transformations // need to be introduced in futher, we should probably refactor them for better orthogonal // composition. (e.g. use decorator-like patterns rather than the current class inheritance?) @@ -86,6 +87,9 @@ abstract class Scale } } + /** + * @final NEVER override! + */ getSetting(name: KEY): SETTING[KEY] { return this._setting[name]; } @@ -115,22 +119,19 @@ abstract class Scale abstract scale(val: number): number; /** - * [CAVEAT]: It should not be overridden! + * @final NEVER override! */ - _innerUnionExtent(other: number[]): void { + innerUnionExtent(other: number[]): void { const extent = this._extent; // Considered that number could be NaN and should not write into the extent. - this._innerSetExtent( + this.setExtent( other[0] < extent[0] ? other[0] : extent[0], other[1] > extent[1] ? other[1] : extent[1] ); } - /** - * Set extent from data - */ unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { - this._innerUnionExtent(data.getApproximateExtent(dim)); + this.innerUnionExtent(data.getApproximateExtent(dim)); } /** @@ -142,13 +143,6 @@ abstract class Scale } setExtent(start: number, end: number): void { - this._innerSetExtent(start, end); - } - - /** - * [CAVEAT]: It should not be overridden! - */ - protected _innerSetExtent(start: number, end: number): void { const thisExtent = this._extent; if (!isNaN(start)) { thisExtent[0] = start; @@ -167,53 +161,63 @@ abstract class Scale ): void { const scaleBreakHelper = getScaleBreakHelper(); if (scaleBreakHelper) { - this._innerSetBreak( + this.innerSetBreak( scaleBreakHelper.parseAxisBreakOption(breakOptionList, bind(this.parse, this)) ); } } /** - * [CAVEAT]: It should not be overridden! + * @final NEVER override! */ - _innerSetBreak(parsed: AxisBreakParsingResult) { - if (this._brkCtx) { - this._brkCtx.setBreaks(parsed); - this._calculator.updateMethods(this._brkCtx); - this._brkCtx.update(this._extent); + innerSetBreak(parsed: AxisBreakParsingResult) { + const brkCtx = this._brkCtx; + if (brkCtx) { + brkCtx.setBreaks(parsed); + this._calculator.updateMethods(brkCtx); + brkCtx.update(this._extent); } } /** - * [CAVEAT]: It should not be overridden! + * @final NEVER override! */ - _innerGetBreaks(): ParsedAxisBreakList { - return this._brkCtx ? this._brkCtx.breaks : []; + innerGetBreaks(): ParsedAxisBreakList { + const brkCtx = this._brkCtx; + return brkCtx ? brkCtx.breaks : []; } /** * Do not expose the internal `_breaks` unless necessary. + * + * @final NEVER override! */ hasBreaks(): boolean { - return this._brkCtx ? this._brkCtx.hasBreaks() : false; - } - - protected _getExtentSpanWithBreaks() { - return (this._brkCtx && this._brkCtx.hasBreaks()) - ? this._brkCtx.getExtentSpan() - : this._extent[1] - this._extent[0]; + const brkCtx = this._brkCtx; + return brkCtx ? brkCtx.hasBreaks() : false; } /** - * If value is in extent range + * @final NEVER override! */ - isInExtentRange(value: number): boolean { - return this._extent[0] <= value && this._extent[1] >= value; + getBreaksElapsedExtentSpan() { + const brkCtx = this._brkCtx; + const extent = this._extent; + return (brkCtx && brkCtx.hasBreaks()) + ? brkCtx.getExtentSpan() + : extent[1] - extent[0]; + } + + isInExtent(value: number): boolean { + const extent = this._extent; + return extent[0] <= value && extent[1] >= value; } /** * When axis extent depends on data and no data exists, * axis ticks should not be drawn, which is named 'blank'. + * + * @final NEVER override! */ isBlank(): boolean { return this._isBlank; @@ -222,31 +226,13 @@ abstract class Scale /** * When axis extent depends on data and no data exists, * axis ticks should not be drawn, which is named 'blank'. + * + * @final NEVER override! */ setBlank(isBlank: boolean) { this._isBlank = isBlank; } - /** - * Update interval and extent of intervals for nice ticks - * - * @param splitNumber Approximated tick numbers. Optional. - * The implementation of `niceTicks` should decide tick numbers - * whether `splitNumber` is given. - * @param minInterval Optional. - * @param maxInterval Optional. - */ - abstract calcNiceTicks( - // FIXME:TS make them in a "opt", the same with `niceExtent`? - splitNumber?: number, - minInterval?: number, - maxInterval?: number - ): void; - - abstract calcNiceExtent( - opt?: {} - ): void; - /** * @return label of the tick. */ diff --git a/src/scale/Time.ts b/src/scale/Time.ts index 3549384d6f..5785dc16b6 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -75,7 +75,6 @@ import { roundTime } from '../util/time'; import * as scaleHelper from './helper'; -import IntervalScale from './Interval'; import Scale, { ScaleGetTicksOpt } from './Scale'; import {TimeScaleTick, ScaleTick, AxisBreakOption, NullUndefined} from '../util/types'; import {TimeAxisLabelFormatterParsed} from '../coord/axisCommonTypes'; @@ -84,6 +83,8 @@ import { LocaleOption } from '../core/locale'; import Model from '../model/Model'; import { each, filter, indexOf, isNumber, map } from 'zrender/src/core/util'; import { ScaleBreakContext, getScaleBreakHelper } from './break'; +import type { ScaleCalcNiceMethod } from '../coord/axisNiceTicks'; +import { getMinorTicks } from './minorTicks'; // FIXME 公用? const bisect = function ( @@ -110,12 +111,13 @@ type TimeScaleSetting = { modelAxisBreaks?: AxisBreakOption[]; }; -class TimeScale extends IntervalScale { +class TimeScale extends Scale { static type = 'time'; - readonly type = 'time'; + readonly type = 'time' as const; private _approxInterval: number; + private _interval: number = 0; private _minLevelUnit: TimeUnit; @@ -186,7 +188,7 @@ class TimeScale extends IntervalScale { this._approxInterval, useUTC, extent, - this._getExtentSpanWithBreaks(), + this.getBreaksElapsedExtentSpan(), this._brkCtx ); @@ -251,56 +253,23 @@ class TimeScale extends IntervalScale { return ticks; } - calcNiceExtent( - opt?: { - splitNumber?: number, - minInterval?: number, - maxInterval?: number - } - ): void { - const extent = this.getExtent(); - // If extent start and end are same, expand them - if (extent[0] === extent[1]) { - // Expand extent - extent[0] -= ONE_DAY; - extent[1] += ONE_DAY; - } - // If there are no data and extent are [Infinity, -Infinity] - if (extent[1] === -Infinity && extent[0] === Infinity) { - const d = new Date(); - extent[1] = +new Date(d.getFullYear(), d.getMonth(), d.getDate()); - extent[0] = extent[1] - ONE_DAY; - } - this._innerSetExtent(extent[0], extent[1]); - - this.calcNiceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval); - } - - calcNiceTicks(approxTickNum: number, minInterval: number, maxInterval: number): void { - approxTickNum = approxTickNum || 10; - - const span = this._getExtentSpanWithBreaks(); - this._approxInterval = span / approxTickNum; - - if (minInterval != null && this._approxInterval < minInterval) { - this._approxInterval = minInterval; - } - if (maxInterval != null && this._approxInterval > maxInterval) { - this._approxInterval = maxInterval; - } - - const scaleIntervalsLen = scaleIntervals.length; - const idx = Math.min( - bisect(scaleIntervals, this._approxInterval, 0, scaleIntervalsLen), - scaleIntervalsLen - 1 + getMinorTicks(splitNumber: number): number[][] { + return getMinorTicks( + this, + splitNumber, + this.innerGetBreaks(), + this._interval ); + } - // Interval that can be used to calculate ticks - this._interval = scaleIntervals[idx][1]; - this._intervalPrecision = scaleHelper.getIntervalPrecision(this._interval); - // Min level used when picking ticks from top down. - // We check one more level to avoid the ticks are to sparse in some case. - this._minLevelUnit = scaleIntervals[Math.max(idx - 1, 0)][0]; + setTimeInterval(opt: { + interval: number; + approxInterval: number; + minLevelUnit: TimeUnit; + }): void { + this._interval = opt.interval; + this._approxInterval = opt.approxInterval; + this._minLevelUnit = opt.minLevelUnit; } parse(val: number | string | Date): number { @@ -576,7 +545,7 @@ function getIntervalTicks( } } - // This extra tick is for calcuating ticks of next level. Will not been added to the final result + // This extra tick is for calculating ticks of next level. Will not been added to the final result out.push({ value: dateTime, notAdd: true @@ -764,6 +733,53 @@ function getIntervalTicks( return result; } +export const timeScaleCalcNice: ScaleCalcNiceMethod = function (scale: TimeScale, opt) { + const extent = scale.getExtent(); + // If extent start and end are same, expand them + if (extent[0] === extent[1]) { + // Expand extent + extent[0] -= ONE_DAY; + extent[1] += ONE_DAY; + } + // If there are no data and extent are [Infinity, -Infinity] + if (extent[1] === -Infinity && extent[0] === Infinity) { + const d = new Date(); + extent[1] = +new Date(d.getFullYear(), d.getMonth(), d.getDate()); + extent[0] = extent[1] - ONE_DAY; + } + scale.setExtent(extent[0], extent[1]); + + const splitNumber = scaleHelper.ensureValidSplitNumber(opt.splitNumber, 10); + const span = scale.getBreaksElapsedExtentSpan(); + let approxInterval = span / splitNumber; + + const minInterval = opt.minInterval; + const maxInterval = opt.maxInterval; + if (minInterval != null && approxInterval < minInterval) { + approxInterval = minInterval; + } + if (maxInterval != null && approxInterval > maxInterval) { + approxInterval = maxInterval; + } + + const scaleIntervalsLen = scaleIntervals.length; + const idx = Math.min( + bisect(scaleIntervals, approxInterval, 0, scaleIntervalsLen), + scaleIntervalsLen - 1 + ); + + // Interval that can be used to calculate ticks + const interval = scaleIntervals[idx][1]; + // Min level used when picking ticks from top down. + // We check one more level to avoid the ticks are to sparse in some case. + const minLevelUnit = scaleIntervals[Math.max(idx - 1, 0)][0]; + + scale.setTimeInterval({ + approxInterval, + interval, + minLevelUnit + }); +}; Scale.registerClass(TimeScale); diff --git a/src/scale/helper.ts b/src/scale/helper.ts index bb10ae051d..b290282812 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -20,7 +20,7 @@ import { getPrecision, round, nice, quantityExponent, mathPow, mathMax, mathRound, - mathLog, mathAbs, mathFloor, mathCeil, mathMin + mathLog, mathAbs, mathFloor, mathCeil } from '../util/number'; import IntervalScale from './Interval'; import LogScale from './Log'; @@ -36,6 +36,13 @@ type intervalScaleNiceTicksResult = { niceTickExtent: [number, number] }; +export type IntervalScaleGetLabelOpt = { + // If 'auto', use nice precision. + precision?: 'auto' | number, + // `true`: returns 1.50 but not 1.5 if precision is 2. + pad?: boolean +}; + /** * See also method `nice` in `src/util/number.ts`. */ @@ -286,7 +293,7 @@ export function intervalScaleEnsureValidExtent( } } const span = extent[1] - extent[0]; - // If there are no data and extent are [Infinity, -Infinity] + // If there are no series data, extent may be `[Infinity, -Infinity]` here. if (!isFinite(span)) { extent[0] = 0; extent[1] = 1; diff --git a/src/scale/minorTicks.ts b/src/scale/minorTicks.ts new file mode 100644 index 0000000000..85b8cf953e --- /dev/null +++ b/src/scale/minorTicks.ts @@ -0,0 +1,81 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { round } from '../util/number'; +import { ParsedAxisBreakList } from '../util/types'; +import { getScaleBreakHelper } from './break'; +import { getIntervalPrecision } from './helper'; +import Scale from './Scale'; + + +export function getMinorTicks( + scale: Scale, + splitNumber: number, + breaks: ParsedAxisBreakList, + scaleInterval: number +): number[][] { + const ticks = scale.getTicks({ + expandToNicedExtent: true, + }); + // NOTE: In log-scale, do not support minor ticks when breaks exist. + // because currently log-scale minor ticks is calculated based on raw values + // rather than log-transformed value, due to an odd effect when breaks exist. + const minorTicks = []; + const extent = scale.getExtent(); + + for (let i = 1; i < ticks.length; i++) { + const nextTick = ticks[i]; + const prevTick = ticks[i - 1]; + + if (prevTick.break || nextTick.break) { + // Do not build minor ticks to the adjacent ticks to breaks ticks, + // since the interval might be irregular. + continue; + } + + let count = 0; + const minorTicksGroup = []; + const interval = nextTick.value - prevTick.value; + const minorInterval = interval / splitNumber; + const minorIntervalPrecision = getIntervalPrecision(minorInterval); + + while (count < splitNumber - 1) { + const minorTick = round(prevTick.value + (count + 1) * minorInterval, minorIntervalPrecision); + + // For the first and last interval. The count may be less than splitNumber. + if (minorTick > extent[0] && minorTick < extent[1]) { + minorTicksGroup.push(minorTick); + } + count++; + } + + const scaleBreakHelper = getScaleBreakHelper(); + scaleBreakHelper && scaleBreakHelper.pruneTicksByBreak( + 'auto', + minorTicksGroup, + breaks, + value => value, + scaleInterval, + extent + ); + minorTicks.push(minorTicksGroup); + } + + return minorTicks; +} diff --git a/src/util/jitter.ts b/src/util/jitter.ts index 42690c1ddd..a98a5d579c 100644 --- a/src/util/jitter.ts +++ b/src/util/jitter.ts @@ -61,7 +61,7 @@ export function fixJitter( ): number { if (fixedAxis instanceof Axis2D) { const scaleType = fixedAxis.scale.type; - if (scaleType !== 'category' && scaleType !== 'ordinal') { + if (scaleType !== 'ordinal') { return floatCoord; } } diff --git a/test/axis-align-ticks.html b/test/axis-align-ticks.html index e22f2a99c8..8b98a1b4f5 100644 --- a/test/axis-align-ticks.html +++ b/test/axis-align-ticks.html @@ -41,7 +41,7 @@
-
+
@@ -305,7 +305,7 @@ ] } - var chart = testHelper.create(echarts, 'main4', { + var chart = testHelper.create(echarts, 'main_Value_axis_can_alignTicks_to_log_axis', { title: [ 'Value axis can alignTicks to log axis' ], diff --git a/test/runTest/actions/axis-align-ticks.json b/test/runTest/actions/axis-align-ticks.json index e69e61501f..b4d7a94ea0 100644 --- a/test/runTest/actions/axis-align-ticks.json +++ b/test/runTest/actions/axis-align-ticks.json @@ -1 +1 @@ -[{"name":"Action 1","ops":[{"type":"mousedown","time":331,"x":288,"y":71},{"type":"mouseup","time":425,"x":288,"y":71},{"time":426,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":570,"x":288,"y":71},{"type":"mousemove","time":770,"x":375,"y":70},{"type":"mousedown","time":976,"x":381,"y":70},{"type":"mousemove","time":981,"x":381,"y":70},{"type":"mouseup","time":1065,"x":381,"y":70},{"time":1066,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1337,"x":383,"y":70},{"type":"mousemove","time":1537,"x":482,"y":69},{"type":"mousemove","time":1742,"x":485,"y":69},{"type":"mousedown","time":1758,"x":485,"y":69},{"type":"mouseup","time":1832,"x":485,"y":69},{"time":1833,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2087,"x":484,"y":69},{"type":"mousemove","time":2287,"x":345,"y":61},{"type":"mousemove","time":2487,"x":346,"y":64},{"type":"mousemove","time":2692,"x":354,"y":66},{"type":"mousedown","time":2726,"x":354,"y":66},{"type":"mouseup","time":2790,"x":354,"y":66},{"time":2791,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2903,"x":352,"y":66},{"type":"mousemove","time":3103,"x":330,"y":68},{"type":"mousemove","time":3308,"x":311,"y":65},{"type":"mousedown","time":3629,"x":311,"y":65},{"type":"mouseup","time":3692,"x":311,"y":65},{"time":3693,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4104,"x":312,"y":65},{"type":"mousemove","time":4307,"x":423,"y":68},{"type":"mousemove","time":4520,"x":474,"y":67},{"type":"mousedown","time":4595,"x":474,"y":67},{"type":"mouseup","time":4676,"x":474,"y":67},{"time":4677,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5703,"x":474,"y":67}],"scrollY":0,"scrollX":0,"timestamp":1640758619162},{"name":"Action 2","ops":[{"type":"mousedown","time":292,"x":300,"y":139},{"type":"mouseup","time":409,"x":300,"y":139},{"time":410,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":663,"x":301,"y":139},{"type":"mousemove","time":867,"x":466,"y":141},{"type":"mousemove","time":1165,"x":473,"y":139},{"type":"mousedown","time":1192,"x":473,"y":139},{"type":"mouseup","time":1298,"x":473,"y":139},{"time":1299,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1512,"x":472,"y":139},{"type":"mousemove","time":1717,"x":405,"y":136},{"type":"mousedown","time":1875,"x":405,"y":136},{"type":"mouseup","time":1950,"x":405,"y":136},{"time":1951,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":2676,"x":405,"y":136},{"type":"mouseup","time":2784,"x":405,"y":136},{"time":2785,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2962,"x":404,"y":136},{"type":"mousemove","time":3162,"x":346,"y":137},{"type":"mousemove","time":3368,"x":309,"y":138},{"type":"mousedown","time":3489,"x":309,"y":138},{"type":"mouseup","time":3592,"x":309,"y":138},{"time":3593,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3896,"x":309,"y":138},{"type":"mousemove","time":4096,"x":425,"y":135},{"type":"mousemove","time":4301,"x":465,"y":137},{"type":"mousemove","time":4516,"x":475,"y":137},{"type":"mousedown","time":4636,"x":475,"y":137},{"type":"mouseup","time":4717,"x":475,"y":137},{"time":4718,"delay":400,"type":"screenshot-auto"}],"scrollY":408,"scrollX":0,"timestamp":1640758633004},{"name":"Action 3","ops":[{"type":"mousedown","time":430,"x":301,"y":94},{"type":"mouseup","time":535,"x":301,"y":94},{"time":536,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":821,"x":302,"y":94},{"type":"mousemove","time":1021,"x":378,"y":95},{"type":"mousemove","time":1221,"x":385,"y":96},{"type":"mousedown","time":1297,"x":385,"y":96},{"type":"mousemove","time":1338,"x":385,"y":96},{"type":"mouseup","time":1369,"x":385,"y":97},{"time":1370,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1543,"x":386,"y":97},{"type":"mousemove","time":1621,"x":386,"y":96},{"type":"mousemove","time":1827,"x":402,"y":95},{"type":"mousedown","time":2243,"x":402,"y":95},{"type":"mouseup","time":2325,"x":402,"y":95},{"time":2326,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2455,"x":402,"y":95},{"type":"mousemove","time":2655,"x":365,"y":103},{"type":"mousemove","time":2859,"x":331,"y":104},{"type":"mousemove","time":3072,"x":323,"y":103},{"type":"mousedown","time":3297,"x":323,"y":103},{"type":"mouseup","time":3376,"x":323,"y":103},{"time":3377,"delay":400,"type":"screenshot-auto"}],"scrollY":907,"scrollX":0,"timestamp":1640758671814},{"name":"Action 4","ops":[{"type":"mousedown","time":230,"x":553,"y":567},{"type":"mousemove","time":324,"x":553,"y":567},{"type":"mousemove","time":525,"x":425,"y":566},{"type":"mouseup","time":617,"x":420,"y":566},{"time":618,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":733,"x":420,"y":566},{"type":"mousedown","time":1117,"x":420,"y":566},{"type":"mousemove","time":1191,"x":420,"y":566},{"type":"mousemove","time":1398,"x":250,"y":569},{"type":"mousemove","time":1622,"x":202,"y":572},{"type":"mouseup","time":1683,"x":202,"y":572},{"time":1684,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2241,"x":201,"y":572},{"type":"mousedown","time":2267,"x":201,"y":572},{"type":"mousemove","time":2441,"x":148,"y":576},{"type":"mousemove","time":2641,"x":120,"y":576},{"type":"mousemove","time":2892,"x":118,"y":576},{"type":"mousemove","time":3096,"x":67,"y":580},{"type":"mouseup","time":3183,"x":67,"y":580},{"time":3184,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3275,"x":67,"y":580},{"type":"mousemove","time":3475,"x":66,"y":581},{"type":"mousemove","time":3675,"x":162,"y":586},{"type":"mousemove","time":3875,"x":196,"y":581},{"type":"mousemove","time":4075,"x":214,"y":577},{"type":"mousemove","time":4276,"x":207,"y":578},{"type":"mousemove","time":4541,"x":206,"y":578},{"type":"mousemove","time":4742,"x":202,"y":579},{"type":"mousedown","time":4807,"x":202,"y":579},{"type":"mousemove","time":4858,"x":202,"y":579},{"type":"mousemove","time":5058,"x":712,"y":572},{"type":"mousemove","time":5259,"x":766,"y":570}],"scrollY":2166,"scrollX":0,"timestamp":1640758695695}] \ No newline at end of file +[{"name":"Action 1","ops":[{"type":"mousedown","time":331,"x":288,"y":71},{"type":"mouseup","time":425,"x":288,"y":71},{"time":426,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":570,"x":288,"y":71},{"type":"mousemove","time":770,"x":375,"y":70},{"type":"mousedown","time":976,"x":381,"y":70},{"type":"mousemove","time":981,"x":381,"y":70},{"type":"mouseup","time":1065,"x":381,"y":70},{"time":1066,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1337,"x":383,"y":70},{"type":"mousemove","time":1537,"x":482,"y":69},{"type":"mousemove","time":1742,"x":485,"y":69},{"type":"mousedown","time":1758,"x":485,"y":69},{"type":"mouseup","time":1832,"x":485,"y":69},{"time":1833,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2087,"x":484,"y":69},{"type":"mousemove","time":2287,"x":345,"y":61},{"type":"mousemove","time":2487,"x":346,"y":64},{"type":"mousemove","time":2692,"x":354,"y":66},{"type":"mousedown","time":2726,"x":354,"y":66},{"type":"mouseup","time":2790,"x":354,"y":66},{"time":2791,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2903,"x":352,"y":66},{"type":"mousemove","time":3103,"x":330,"y":68},{"type":"mousemove","time":3308,"x":311,"y":65},{"type":"mousedown","time":3629,"x":311,"y":65},{"type":"mouseup","time":3692,"x":311,"y":65},{"time":3693,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4104,"x":312,"y":65},{"type":"mousemove","time":4307,"x":423,"y":68},{"type":"mousemove","time":4520,"x":474,"y":67},{"type":"mousedown","time":4595,"x":474,"y":67},{"type":"mouseup","time":4676,"x":474,"y":67},{"time":4677,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5703,"x":474,"y":67}],"scrollY":0,"scrollX":0,"timestamp":1640758619162},{"name":"Action 2","ops":[{"type":"mousedown","time":292,"x":300,"y":139},{"type":"mouseup","time":409,"x":300,"y":139},{"time":410,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":663,"x":301,"y":139},{"type":"mousemove","time":867,"x":466,"y":141},{"type":"mousemove","time":1165,"x":473,"y":139},{"type":"mousedown","time":1192,"x":473,"y":139},{"type":"mouseup","time":1298,"x":473,"y":139},{"time":1299,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1512,"x":472,"y":139},{"type":"mousemove","time":1717,"x":405,"y":136},{"type":"mousedown","time":1875,"x":405,"y":136},{"type":"mouseup","time":1950,"x":405,"y":136},{"time":1951,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":2676,"x":405,"y":136},{"type":"mouseup","time":2784,"x":405,"y":136},{"time":2785,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2962,"x":404,"y":136},{"type":"mousemove","time":3162,"x":346,"y":137},{"type":"mousemove","time":3368,"x":309,"y":138},{"type":"mousedown","time":3489,"x":309,"y":138},{"type":"mouseup","time":3592,"x":309,"y":138},{"time":3593,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3896,"x":309,"y":138},{"type":"mousemove","time":4096,"x":425,"y":135},{"type":"mousemove","time":4301,"x":465,"y":137},{"type":"mousemove","time":4516,"x":475,"y":137},{"type":"mousedown","time":4636,"x":475,"y":137},{"type":"mouseup","time":4717,"x":475,"y":137},{"time":4718,"delay":400,"type":"screenshot-auto"}],"scrollY":408,"scrollX":0,"timestamp":1640758633004},{"name":"Action 3","ops":[{"type":"mousedown","time":430,"x":301,"y":94},{"type":"mouseup","time":535,"x":301,"y":94},{"time":536,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":821,"x":302,"y":94},{"type":"mousemove","time":1021,"x":378,"y":95},{"type":"mousemove","time":1221,"x":385,"y":96},{"type":"mousedown","time":1297,"x":385,"y":96},{"type":"mousemove","time":1338,"x":385,"y":96},{"type":"mouseup","time":1369,"x":385,"y":97},{"time":1370,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1543,"x":386,"y":97},{"type":"mousemove","time":1621,"x":386,"y":96},{"type":"mousemove","time":1827,"x":402,"y":95},{"type":"mousedown","time":2243,"x":402,"y":95},{"type":"mouseup","time":2325,"x":402,"y":95},{"time":2326,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2455,"x":402,"y":95},{"type":"mousemove","time":2655,"x":365,"y":103},{"type":"mousemove","time":2859,"x":331,"y":104},{"type":"mousemove","time":3072,"x":323,"y":103},{"type":"mousedown","time":3297,"x":323,"y":103},{"type":"mouseup","time":3376,"x":323,"y":103},{"time":3377,"delay":400,"type":"screenshot-auto"}],"scrollY":907,"scrollX":0,"timestamp":1640758671814},{"name":"Action 4","ops":[{"type":"mousemove","time":507,"x":769,"y":254},{"type":"mousemove","time":708,"x":664,"y":384},{"type":"mousemove","time":921,"x":556,"y":515},{"type":"mousemove","time":1113,"x":554,"y":517},{"type":"mousemove","time":1314,"x":539,"y":540},{"type":"mousemove","time":1519,"x":528,"y":554},{"type":"mousemove","time":1720,"x":530,"y":556},{"type":"mousemove","time":1929,"x":531,"y":556},{"type":"mousedown","time":2031,"x":531,"y":556},{"type":"mousemove","time":2040,"x":531,"y":556},{"type":"mousemove","time":2253,"x":436,"y":548},{"type":"mousemove","time":2459,"x":413,"y":548},{"type":"mousemove","time":2579,"x":412,"y":548},{"type":"mousemove","time":2783,"x":296,"y":549},{"type":"mousemove","time":2984,"x":218,"y":549},{"type":"mousemove","time":3205,"x":215,"y":549},{"type":"mousemove","time":3405,"x":221,"y":548},{"type":"mousemove","time":3606,"x":245,"y":544},{"type":"mousemove","time":3810,"x":264,"y":543},{"type":"mousemove","time":4011,"x":274,"y":543},{"type":"mousemove","time":4214,"x":282,"y":543},{"type":"mousemove","time":4418,"x":304,"y":544},{"type":"mousemove","time":4621,"x":330,"y":544},{"type":"mousemove","time":4837,"x":363,"y":542},{"type":"mousemove","time":5053,"x":395,"y":540},{"type":"mouseup","time":5335,"x":395,"y":540},{"time":5336,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5346,"x":423,"y":541},{"type":"mousemove","time":5557,"x":517,"y":552},{"type":"mousemove","time":5764,"x":504,"y":565},{"type":"mousemove","time":5965,"x":495,"y":571},{"type":"mousedown","time":6346,"x":495,"y":571},{"type":"mousemove","time":6355,"x":495,"y":571},{"type":"mousemove","time":6556,"x":593,"y":567},{"type":"mousemove","time":6760,"x":633,"y":567},{"type":"mousemove","time":6961,"x":634,"y":567},{"type":"mousemove","time":7012,"x":634,"y":567},{"type":"mousemove","time":7213,"x":630,"y":565},{"type":"mousemove","time":7416,"x":598,"y":564},{"type":"mousemove","time":7616,"x":559,"y":562},{"type":"mousemove","time":7820,"x":501,"y":563},{"type":"mousemove","time":8032,"x":486,"y":562},{"type":"mousemove","time":8236,"x":485,"y":562},{"type":"mouseup","time":8418,"x":485,"y":562},{"time":8419,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8427,"x":464,"y":559},{"type":"mousemove","time":8627,"x":359,"y":561},{"type":"mousemove","time":8833,"x":362,"y":571},{"type":"mousemove","time":9033,"x":376,"y":571},{"type":"mousemove","time":9234,"x":380,"y":571},{"type":"mousemove","time":9447,"x":380,"y":571},{"type":"mousedown","time":9547,"x":380,"y":571},{"type":"mousemove","time":9556,"x":380,"y":571},{"type":"mousemove","time":9765,"x":237,"y":561},{"type":"mousemove","time":9971,"x":160,"y":552},{"type":"mousemove","time":10186,"x":133,"y":550},{"type":"mousemove","time":10397,"x":113,"y":549},{"type":"mousemove","time":10608,"x":135,"y":546},{"type":"mousemove","time":10817,"x":170,"y":548},{"type":"mouseup","time":10973,"x":170,"y":548},{"time":10974,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10982,"x":158,"y":557},{"type":"mousemove","time":11187,"x":127,"y":567},{"type":"mousemove","time":11393,"x":106,"y":578},{"type":"mousedown","time":11458,"x":105,"y":579},{"type":"mouseup","time":11560,"x":105,"y":579},{"time":11561,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11605,"x":105,"y":579},{"type":"mousemove","time":12201,"x":104,"y":579},{"type":"mousemove","time":12402,"x":53,"y":579},{"type":"mousedown","time":12482,"x":45,"y":579},{"type":"mouseup","time":12599,"x":45,"y":579},{"time":12600,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12635,"x":45,"y":579},{"type":"mousemove","time":12857,"x":62,"y":579},{"type":"mousemove","time":13061,"x":67,"y":579},{"type":"mousemove","time":13262,"x":75,"y":576},{"type":"mousemove","time":13466,"x":88,"y":576},{"type":"mousedown","time":13594,"x":98,"y":576},{"type":"mousemove","time":13666,"x":98,"y":576},{"type":"mouseup","time":13694,"x":98,"y":576},{"time":13695,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13785,"x":96,"y":576},{"type":"mousemove","time":13986,"x":57,"y":576},{"type":"mousemove","time":14201,"x":45,"y":576},{"type":"mousemove","time":14543,"x":45,"y":575},{"type":"mousedown","time":15412,"x":45,"y":575},{"type":"mouseup","time":15547,"x":45,"y":575},{"time":15548,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16397,"x":46,"y":575},{"type":"mousemove","time":16597,"x":72,"y":575},{"type":"mousemove","time":16801,"x":188,"y":573},{"type":"mousemove","time":17002,"x":244,"y":569},{"type":"mousemove","time":17212,"x":247,"y":568},{"type":"mousemove","time":17526,"x":257,"y":559},{"type":"mousemove","time":17727,"x":330,"y":471},{"type":"mousewheel","time":17864,"x":330,"y":471,"deltaY":1},{"type":"mousewheel","time":17903,"x":330,"y":471,"deltaY":5},{"type":"mousewheel","time":17926,"x":330,"y":471,"deltaY":19},{"type":"mousewheel","time":17949,"x":330,"y":471,"deltaY":13},{"type":"mousewheel","time":17972,"x":330,"y":471,"deltaY":14},{"type":"mousewheel","time":18008,"x":330,"y":471,"deltaY":23},{"type":"mousewheel","time":18032,"x":330,"y":471,"deltaY":15},{"type":"mousewheel","time":18054,"x":330,"y":471,"deltaY":5},{"type":"mousewheel","time":18079,"x":330,"y":471,"deltaY":5},{"type":"mousewheel","time":18110,"x":330,"y":471,"deltaY":2},{"type":"mousewheel","time":18133,"x":330,"y":471,"deltaY":1},{"type":"mousewheel","time":18153,"x":330,"y":471,"deltaY":1},{"type":"mousewheel","time":18224,"x":330,"y":471,"deltaY":1},{"type":"mousewheel","time":18248,"x":330,"y":471,"deltaY":2},{"type":"mousewheel","time":18269,"x":330,"y":471,"deltaY":6},{"type":"mousewheel","time":18290,"x":330,"y":471,"deltaY":15},{"type":"mousewheel","time":18313,"x":330,"y":471,"deltaY":6},{"type":"mousewheel","time":18343,"x":330,"y":471,"deltaY":8},{"type":"mousewheel","time":18365,"x":330,"y":471,"deltaY":2},{"type":"mousewheel","time":18706,"x":330,"y":471,"deltaY":1},{"type":"mousewheel","time":18734,"x":330,"y":471,"deltaY":6},{"type":"mousewheel","time":18754,"x":330,"y":471,"deltaY":25},{"type":"mousewheel","time":18775,"x":330,"y":471,"deltaY":26},{"type":"mousewheel","time":18795,"x":330,"y":471,"deltaY":34},{"type":"mousewheel","time":18818,"x":330,"y":471,"deltaY":48},{"type":"mousewheel","time":18846,"x":330,"y":471,"deltaY":72},{"type":"mousewheel","time":18868,"x":330,"y":471,"deltaY":242},{"type":"mousewheel","time":18892,"x":330,"y":471,"deltaY":77},{"type":"mousewheel","time":18913,"x":330,"y":471,"deltaY":147},{"type":"mousewheel","time":18934,"x":330,"y":471,"deltaY":72},{"type":"mousewheel","time":18961,"x":330,"y":471,"deltaY":128},{"type":"mousewheel","time":18981,"x":330,"y":471,"deltaY":58},{"type":"mousewheel","time":19002,"x":330,"y":471,"deltaY":55},{"type":"mousewheel","time":19022,"x":330,"y":471,"deltaY":51},{"type":"mousewheel","time":19043,"x":330,"y":471,"deltaY":47},{"type":"mousewheel","time":19074,"x":330,"y":471,"deltaY":83},{"type":"mousewheel","time":19096,"x":330,"y":471,"deltaY":71},{"type":"mousewheel","time":19118,"x":330,"y":471,"deltaY":31},{"type":"mousewheel","time":19138,"x":330,"y":471,"deltaY":28},{"type":"mousewheel","time":19158,"x":330,"y":471,"deltaY":26},{"type":"mousewheel","time":19187,"x":330,"y":471,"deltaY":46},{"type":"mousewheel","time":19209,"x":330,"y":471,"deltaY":20},{"type":"mousewheel","time":19230,"x":330,"y":471,"deltaY":36},{"type":"mousewheel","time":19252,"x":330,"y":471,"deltaY":16},{"type":"mousewheel","time":19271,"x":330,"y":471,"deltaY":14},{"type":"mousewheel","time":19301,"x":330,"y":471,"deltaY":25},{"type":"mousewheel","time":19322,"x":330,"y":471,"deltaY":11},{"type":"mousewheel","time":19344,"x":330,"y":471,"deltaY":19},{"type":"mousewheel","time":19366,"x":330,"y":471,"deltaY":8},{"type":"mousewheel","time":19387,"x":330,"y":471,"deltaY":8},{"type":"mousewheel","time":19416,"x":330,"y":471,"deltaY":13},{"type":"mousewheel","time":19437,"x":330,"y":471,"deltaY":6},{"type":"mousewheel","time":19459,"x":330,"y":471,"deltaY":5},{"type":"mousewheel","time":19481,"x":330,"y":471,"deltaY":10},{"type":"mousewheel","time":19502,"x":330,"y":471,"deltaY":4},{"type":"mousewheel","time":19530,"x":330,"y":471,"deltaY":8},{"type":"mousewheel","time":19555,"x":330,"y":471,"deltaY":3},{"type":"mousewheel","time":19575,"x":330,"y":471,"deltaY":3},{"type":"mousewheel","time":19596,"x":330,"y":471,"deltaY":6},{"type":"mousewheel","time":19621,"x":330,"y":471,"deltaY":2},{"type":"mousewheel","time":19642,"x":330,"y":471,"deltaY":2},{"type":"mousewheel","time":19661,"x":330,"y":471,"deltaY":4},{"type":"mousewheel","time":19784,"x":330,"y":471,"deltaY":-1},{"type":"mousewheel","time":19803,"x":330,"y":471,"deltaY":-1},{"type":"mousewheel","time":19823,"x":330,"y":471,"deltaY":-66},{"type":"mousewheel","time":19848,"x":330,"y":471,"deltaY":-61},{"type":"mousewheel","time":19869,"x":330,"y":471,"deltaY":-86},{"type":"mousewheel","time":19888,"x":330,"y":471,"deltaY":-113},{"type":"mousewheel","time":19908,"x":330,"y":471,"deltaY":-238},{"type":"mousewheel","time":19928,"x":330,"y":471,"deltaY":-116},{"type":"mousewheel","time":19954,"x":330,"y":471,"deltaY":-216},{"type":"mousewheel","time":19975,"x":330,"y":471,"deltaY":-100},{"type":"mousewheel","time":19998,"x":330,"y":471,"deltaY":-96},{"type":"mousewheel","time":20020,"x":330,"y":471,"deltaY":-179},{"type":"mousewheel","time":20041,"x":330,"y":471,"deltaY":-82},{"type":"mousewheel","time":20069,"x":330,"y":471,"deltaY":-77},{"type":"mousewheel","time":20093,"x":330,"y":471,"deltaY":-140},{"type":"mousewheel","time":20115,"x":330,"y":471,"deltaY":-63},{"type":"mousewheel","time":20136,"x":330,"y":471,"deltaY":-114},{"type":"mousewheel","time":20159,"x":330,"y":471,"deltaY":-51},{"type":"mousewheel","time":20190,"x":330,"y":471,"deltaY":-47},{"type":"mousewheel","time":20228,"x":330,"y":471,"deltaY":-4},{"type":"mousewheel","time":20252,"x":330,"y":471,"deltaY":-4},{"type":"mousewheel","time":20279,"x":330,"y":471,"deltaY":-24},{"type":"mousewheel","time":20311,"x":330,"y":471,"deltaY":-83},{"type":"mousewheel","time":20334,"x":330,"y":471,"deltaY":-39},{"type":"mousewheel","time":20357,"x":330,"y":471,"deltaY":-63},{"type":"mousewheel","time":20380,"x":330,"y":471,"deltaY":-147},{"type":"mousewheel","time":20411,"x":330,"y":471,"deltaY":-144},{"type":"mousewheel","time":20436,"x":330,"y":471,"deltaY":-67},{"type":"mousewheel","time":20460,"x":330,"y":471,"deltaY":-136},{"type":"mousewheel","time":20483,"x":330,"y":471,"deltaY":-62},{"type":"mousewheel","time":20517,"x":330,"y":471,"deltaY":-111},{"type":"mousewheel","time":20540,"x":330,"y":471,"deltaY":-98},{"type":"mousewheel","time":20563,"x":330,"y":471,"deltaY":-43},{"type":"mousewheel","time":20585,"x":330,"y":471,"deltaY":-39},{"type":"mousewheel","time":20630,"x":330,"y":471,"deltaY":-2},{"type":"mousewheel","time":20652,"x":330,"y":471,"deltaY":-8},{"type":"mousewheel","time":20673,"x":330,"y":471,"deltaY":-72},{"type":"mousewheel","time":20697,"x":330,"y":471,"deltaY":-62},{"type":"mousewheel","time":20731,"x":330,"y":471,"deltaY":-55},{"type":"mousewheel","time":20754,"x":330,"y":471,"deltaY":-88},{"type":"mousewheel","time":20776,"x":330,"y":471,"deltaY":-296},{"type":"mousewheel","time":20798,"x":330,"y":471,"deltaY":-185},{"type":"mousewheel","time":20835,"x":330,"y":471,"deltaY":-175},{"type":"mousewheel","time":20857,"x":330,"y":471,"deltaY":-81},{"type":"mousewheel","time":20883,"x":330,"y":471,"deltaY":-76},{"type":"mousewheel","time":20904,"x":330,"y":471,"deltaY":-138},{"type":"mousewheel","time":20929,"x":330,"y":471,"deltaY":-62},{"type":"mousewheel","time":20967,"x":330,"y":471,"deltaY":-113},{"type":"mousewheel","time":20992,"x":330,"y":471,"deltaY":-98},{"type":"mousewheel","time":21030,"x":330,"y":471,"deltaY":-2},{"type":"mousewheel","time":21052,"x":330,"y":471,"deltaY":-9},{"type":"mousewheel","time":21072,"x":330,"y":471,"deltaY":-48},{"type":"mousewheel","time":21094,"x":330,"y":471,"deltaY":-64},{"type":"mousewheel","time":21119,"x":330,"y":471,"deltaY":-61},{"type":"mousewheel","time":21144,"x":330,"y":471,"deltaY":-90},{"type":"mousewheel","time":21164,"x":330,"y":471,"deltaY":-201},{"type":"mousewheel","time":21184,"x":330,"y":471,"deltaY":-193},{"type":"mousewheel","time":21206,"x":330,"y":471,"deltaY":-90},{"type":"mousewheel","time":21228,"x":330,"y":471,"deltaY":-90},{"type":"mousewheel","time":21250,"x":330,"y":471,"deltaY":-85},{"type":"mousewheel","time":21272,"x":330,"y":471,"deltaY":-157},{"type":"mousewheel","time":21294,"x":330,"y":471,"deltaY":-72},{"type":"mousewheel","time":21321,"x":330,"y":471,"deltaY":-66},{"type":"mousewheel","time":21339,"x":330,"y":471,"deltaY":-120},{"type":"mousewheel","time":21362,"x":330,"y":471,"deltaY":-54},{"type":"mousewheel","time":21383,"x":330,"y":471,"deltaY":-51},{"type":"mousewheel","time":21405,"x":330,"y":471,"deltaY":-90},{"type":"mousewheel","time":21428,"x":330,"y":471,"deltaY":-40},{"type":"mousewheel","time":21469,"x":330,"y":471,"deltaY":-2},{"type":"mousewheel","time":21492,"x":330,"y":471,"deltaY":-11},{"type":"mousewheel","time":21517,"x":330,"y":471,"deltaY":-20},{"type":"mousewheel","time":21547,"x":330,"y":471,"deltaY":-88},{"type":"mousewheel","time":21570,"x":330,"y":471,"deltaY":-32},{"type":"mousewheel","time":21590,"x":330,"y":471,"deltaY":-64},{"type":"mousewheel","time":21614,"x":330,"y":471,"deltaY":-226},{"type":"mousewheel","time":21639,"x":330,"y":471,"deltaY":-72},{"type":"mousewheel","time":21661,"x":330,"y":471,"deltaY":-142},{"type":"mousewheel","time":21684,"x":330,"y":471,"deltaY":-68},{"type":"mousewheel","time":21707,"x":330,"y":471,"deltaY":-64},{"type":"mousewheel","time":21729,"x":330,"y":471,"deltaY":-116},{"type":"mousewheel","time":21756,"x":330,"y":471,"deltaY":-53},{"type":"mousewheel","time":21777,"x":330,"y":471,"deltaY":-94},{"type":"mousewheel","time":21797,"x":330,"y":471,"deltaY":-41},{"type":"mousewheel","time":21820,"x":330,"y":471,"deltaY":-38},{"type":"mousewheel","time":21844,"x":330,"y":471,"deltaY":-35},{"type":"mousewheel","time":21869,"x":330,"y":471,"deltaY":-62},{"type":"mousewheel","time":21895,"x":330,"y":471,"deltaY":-27},{"type":"mousewheel","time":21924,"x":330,"y":471,"deltaY":-46},{"type":"mousewheel","time":21948,"x":330,"y":471,"deltaY":-39},{"type":"mousemove","time":21971,"x":331,"y":471},{"type":"mousemove","time":22172,"x":366,"y":449}],"scrollY":2166,"scrollX":0,"timestamp":1768470061839}] \ No newline at end of file From 0f4561dbe9679a0dffa9b570fb2603cd0ce25471 Mon Sep 17 00:00:00 2001 From: 100pah Date: Sat, 24 Jan 2026 22:30:50 +0800 Subject: [PATCH 09/31] refactor&fix: (1) Unify series data union code - remove union code from Scale; unify union entry and scaleRawExtentInfo creator and cache to avoid error-prone impl; simplify the impl of coord sys call extent uinon. (2) Fix LogScale precision bug introduced by previous commits. --- src/chart/map/MapSeries.ts | 6 +- src/chart/treemap/treemapLayout.ts | 3 +- src/component/brush/visualEncoding.ts | 3 +- src/component/dataZoom/AxisProxy.ts | 84 +++--- src/component/dataZoom/installCommon.ts | 20 +- src/component/helper/BrushTargetManager.ts | 5 +- src/component/timeline/SliderTimelineView.ts | 4 +- src/coord/axisAlignTicks.ts | 59 ++--- src/coord/axisHelper.ts | 84 +++--- src/coord/axisNiceTicks.ts | 133 ++++------ src/coord/cartesian/Grid.ts | 105 +++----- .../cartesian/defaultAxisExtentFromData.ts | 143 +++++----- src/coord/geo/geoCreator.ts | 4 +- src/coord/parallel/Parallel.ts | 34 +-- src/coord/parallel/parallelCreator.ts | 12 +- src/coord/polar/Polar.ts | 2 + src/coord/polar/polarCreator.ts | 37 +-- src/coord/radar/Radar.ts | 29 +-- src/coord/scaleRawExtentInfo.ts | 244 ++++++++++++++---- src/coord/single/Single.ts | 18 +- src/coord/single/singleCreator.ts | 6 +- src/core/CoordinateSystem.ts | 121 +++++---- src/core/echarts.ts | 15 +- src/data/DataStore.ts | 19 +- src/data/SeriesData.ts | 8 - src/data/helper/dataStackHelper.ts | 3 - src/export/api/helper.ts | 2 +- src/layout/barGrid.ts | 17 +- src/model/Global.ts | 9 +- src/scale/Interval.ts | 19 +- src/scale/Log.ts | 90 +++---- src/scale/Ordinal.ts | 49 ++-- src/scale/Scale.ts | 37 +-- src/scale/Time.ts | 8 +- src/scale/break.ts | 6 +- src/scale/breakImpl.ts | 32 ++- src/scale/helper.ts | 84 ++---- src/util/layout.ts | 9 +- src/util/model.ts | 34 ++- src/util/types.ts | 28 +- 40 files changed, 810 insertions(+), 815 deletions(-) diff --git a/src/chart/map/MapSeries.ts b/src/chart/map/MapSeries.ts index 90a8299df1..8ede0acd34 100644 --- a/src/chart/map/MapSeries.ts +++ b/src/chart/map/MapSeries.ts @@ -47,7 +47,7 @@ import { createTooltipMarkup } from '../../component/tooltip/tooltipMarkup'; import {createSymbol, ECSymbol} from '../../util/symbol'; import {LegendIconParams} from '../../component/legend/LegendModel'; import {Group} from '../../util/graphic'; -import { CoordinateSystemUsageKind, decideCoordSysUsageKind } from '../../core/CoordinateSystem'; +import { COORD_SYS_USAGE_KIND_BOX, decideCoordSysUsageKind } from '../../core/CoordinateSystem'; import { GeoJSONRegion } from '../../coord/geo/Region'; import tokens from '../../visual/tokens'; @@ -164,8 +164,8 @@ class MapSeries extends SeriesModel { * inner exclusive geo model. */ getHostGeoModel(): GeoModel { - if (decideCoordSysUsageKind(this).kind === CoordinateSystemUsageKind.boxCoordSys) { - // Always use an internal geo if specify a boxCoordSys. + if (decideCoordSysUsageKind(this).kind === COORD_SYS_USAGE_KIND_BOX) { + // Always use an internal geo if specify as `COORD_SYS_USAGE_KIND_BOX`. // Notice that currently we do not support laying out a geo based on // another geo, but preserve the possibility. return; diff --git a/src/chart/treemap/treemapLayout.ts b/src/chart/treemap/treemapLayout.ts index 4b870b6d1c..c64def55c6 100644 --- a/src/chart/treemap/treemapLayout.ts +++ b/src/chart/treemap/treemapLayout.ts @@ -38,6 +38,7 @@ import ExtensionAPI from '../../core/ExtensionAPI'; import { TreeNode } from '../../data/Tree'; import Model from '../../model/Model'; import { TreemapRenderPayload, TreemapMovePayload, TreemapZoomToNodePayload } from './treemapAction'; +import { initExtentForUnion } from '../../util/model'; const mathMax = Math.max; const mathMin = Math.min; @@ -464,7 +465,7 @@ function statistic( } // Other dimension. else { - dataExtent = [Infinity, -Infinity]; + dataExtent = initExtentForUnion(); each(children, function (child) { const value = child.getValue(dimension) as number; value < dataExtent[0] && (dataExtent[0] = value); diff --git a/src/component/brush/visualEncoding.ts b/src/component/brush/visualEncoding.ts index 6a221eb218..7f8a421ad1 100644 --- a/src/component/brush/visualEncoding.ts +++ b/src/component/brush/visualEncoding.ts @@ -32,6 +32,7 @@ import SeriesModel from '../../model/Series'; import ParallelSeriesModel from '../../chart/parallel/ParallelSeries'; import { ZRenderType } from 'zrender/src/zrender'; import { BrushType, BrushDimensionMinMax } from '../helper/BrushController'; +import { initExtentForUnion } from '../../util/model'; type BrushVisualState = 'inBrush' | 'outOfBrush'; @@ -319,7 +320,7 @@ const boundingRectBuilders: Partial> const range = area.range as BrushDimensionMinMax[]; for (let i = 0, len = range.length; i < len; i++) { - minMax = minMax || [[Infinity, -Infinity], [Infinity, -Infinity]]; + minMax = minMax || [initExtentForUnion(), initExtentForUnion()]; const rg = range[i]; rg[0] < minMax[0][0] && (minMax[0][0] = rg[0]); rg[0] > minMax[0][1] && (minMax[0][1] = rg[0]); diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 4ba157c7e3..149db865bd 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -29,11 +29,10 @@ import { Dictionary, NullUndefined } from '../../util/types'; // TODO Polar? import DataZoomModel from './DataZoomModel'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; -import { unionAxisExtentFromData } from '../../coord/axisHelper'; -import { ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo'; import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './helper'; -import { SINGLE_REFERRING } from '../../util/model'; +import { initExtentForUnion, SINGLE_REFERRING } from '../../util/model'; import { isOrdinalScale, isTimeScale } from '../../scale/helper'; +import { AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, axisExtentInfoFinalBuild, ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo'; interface MinMaxSpan { @@ -142,7 +141,7 @@ class AxisProxy { /** * [CAVEAT] Keep this method pure, so that it can be called multiple times. * - * Only calculate by given range and this._dataExtent, do not change anything. + * Only calculate by given range and cumulative series data extent, do not change anything. */ calculateDataWindow( opt: { @@ -311,9 +310,20 @@ class AxisProxy { return; } - const targetSeries = this.getTargetSeriesModels(); - // Culculate data window and data extent, and record them. - this._dataExtent = calculateDataExtent(this, this._dimName, targetSeries); + // It is important to get "consistent" extent when more then one axes is + // controlled by a `dataZoom`, otherwise those axes will not be synchronized + // when zooming. But it is difficult to know what is "consistent", considering + // axes have different type or even different meanings (For example, two + // time axes are used to compare data of the same date in different years). + // So basically dataZoom just obtains extent by series.data (in category axis + // extent can be obtained from axis.data). + // Nevertheless, user can set min/max/scale on axes to make extent of axes + // consistent. + const axis = this.getAxisModel().axis; + axisExtentInfoFinalBuild(this.ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); + const rawExtentInfo = ensureScaleRawExtentInfo(axis) + const rawExtentResult = rawExtentInfo.calculate(); + this._dataExtent = [rawExtentResult.min, rawExtentResult.max]; // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); @@ -325,10 +335,18 @@ class AxisProxy { end: alignToPercentInverted[1], }, opt); } - this._window = this.calculateDataWindow(opt); + const {percent, value} = this._window = this.calculateDataWindow(opt); - // Update axis setting then. - this._setAxisModel(); + // For value axis, if min/max/scale are not set, we just use the extent obtained + // by series data, which may be a little different from the extent calculated by + // `axisHelper.getScaleExtent`. But the different just affects the experience a + // little when zooming. So it will not be fixed until some users require it strongly. + if (percent[0] !== 0) { + rawExtentInfo.setDeterminedMinMax('min', value[0]); + } + if (percent[1] !== 100) { + rawExtentInfo.setDeterminedMinMax('max', value[1]); + } } filterData(dataZoomModel: DataZoomModel, api: ExtensionAPI) { @@ -349,8 +367,8 @@ class AxisProxy { // Toolbox may has dataZoom injected. And if there are stacked bar chart // with NaN data, NaN will be filtered and stack will be wrong. // So we need to force the mode to be set empty. - // In fect, it is not a big deal that do not support filterMode-'filter' - // when using toolbox#dataZoom, utill tooltip#dataZoom support "single axis + // In fact, it is not a big deal that do not support filterMode-'filter' + // when using toolbox#dataZoom, util tooltip#dataZoom support "single axis // selection" some day, which might need "adapt to data extent on the // otherAxis", which is disabled by filterMode-'empty'. // But currently, stack has been fixed to based on value but not index, @@ -453,48 +471,6 @@ class AxisProxy { minMaxSpan[minMax + 'ValueSpan' as 'minValueSpan' | 'maxValueSpan'] = valueSpan; }, this); } - - private _setAxisModel() { - - const axisModel = this.getAxisModel(); - - const {percent, value} = this._window; - - // For value axis, if min/max/scale are not set, we just use the extent obtained - // by series data, which may be a little different from the extent calculated by - // `axisHelper.getScaleExtent`. But the different just affects the experience a - // little when zooming. So it will not be fixed until some users require it strongly. - const rawExtentInfo = axisModel.axis.scale.rawExtentInfo; - if (percent[0] !== 0) { - rawExtentInfo.setDeterminedMinMax('min', value[0]); - } - if (percent[1] !== 100) { - rawExtentInfo.setDeterminedMinMax('max', value[1]); - } - rawExtentInfo.freeze(); - } -} - -function calculateDataExtent(axisProxy: AxisProxy, axisDim: string, seriesModels: SeriesModel[]) { - const dataExtent = [Infinity, -Infinity]; - - each(seriesModels, function (seriesModel) { - unionAxisExtentFromData(dataExtent, seriesModel.getData(), axisDim); - }); - - // It is important to get "consistent" extent when more then one axes is - // controlled by a `dataZoom`, otherwise those axes will not be synchronized - // when zooming. But it is difficult to know what is "consistent", considering - // axes have different type or even different meanings (For example, two - // time axes are used to compare data of the same date in different years). - // So basically dataZoom just obtains extent by series.data (in category axis - // extent can be obtained from axis.data). - // Nevertheless, user can set min/max/scale on axes to make extent of axes - // consistent. - const axisModel = axisProxy.getAxisModel(); - const rawExtentResult = ensureScaleRawExtentInfo(axisModel.axis.scale, axisModel, dataExtent).calculate(); - - return [rawExtentResult.min, rawExtentResult.max] as [number, number]; } export default AxisProxy; diff --git a/src/component/dataZoom/installCommon.ts b/src/component/dataZoom/installCommon.ts index 4c70aad1e7..8a667e8124 100644 --- a/src/component/dataZoom/installCommon.ts +++ b/src/component/dataZoom/installCommon.ts @@ -20,20 +20,20 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import dataZoomProcessor from './dataZoomProcessor'; import installDataZoomAction from './dataZoomAction'; +import { makeCallOnlyOnce } from '../../util/model'; + +const callOnlyOnce = makeCallOnlyOnce(); -let installed = false; export default function installCommon(registers: EChartsExtensionInstallRegisters) { - if (installed) { - return; - } - installed = true; - registers.registerProcessor(registers.PRIORITY.PROCESSOR.FILTER, dataZoomProcessor); + callOnlyOnce(registers, function () { + registers.registerProcessor(registers.PRIORITY.PROCESSOR.FILTER, dataZoomProcessor); - installDataZoomAction(registers); + installDataZoomAction(registers); - registers.registerSubTypeDefaulter('dataZoom', function () { - // Default 'slider' when no type specified. - return 'slider'; + registers.registerSubTypeDefaulter('dataZoom', function () { + // Default 'slider' when no type specified. + return 'slider'; + }); }); } \ No newline at end of file diff --git a/src/component/helper/BrushTargetManager.ts b/src/component/helper/BrushTargetManager.ts index 9a378fd001..4fb7170c98 100644 --- a/src/component/helper/BrushTargetManager.ts +++ b/src/component/helper/BrushTargetManager.ts @@ -38,7 +38,8 @@ import { Dictionary } from '../../util/types'; import { ModelFinderObject, ModelFinder, parseFinder as modelUtilParseFinder, - ParsedModelFinderKnown + ParsedModelFinderKnown, + initExtentForUnion } from '../../util/model'; type COORD_CONVERTS_INDEX = 0 | 1; @@ -442,7 +443,7 @@ const coordConvert: Record = { values: BrushDimensionMinMax[], xyMinMax: BrushDimensionMinMax[] } { - const xyMinMax = [[Infinity, -Infinity], [Infinity, -Infinity]]; + const xyMinMax = [initExtentForUnion(), initExtentForUnion()]; const values = map(rangeOrCoordRange, function (item) { const p = to ? coordSys.pointToData(item, clamp) : coordSys.dataToPoint(item, clamp); xyMinMax[0][0] = Math.min(xyMinMax[0][0], p[0]); diff --git a/src/component/timeline/SliderTimelineView.ts b/src/component/timeline/SliderTimelineView.ts index 23fea2bb7a..81b540d3f3 100644 --- a/src/component/timeline/SliderTimelineView.ts +++ b/src/component/timeline/SliderTimelineView.ts @@ -44,7 +44,7 @@ import { createTooltipMarkup } from '../tooltip/tooltipMarkup'; import Displayable from 'zrender/src/graphic/Displayable'; import { createScaleByModel } from '../../coord/axisHelper'; import { OptionAxisType } from '../../coord/axisCommonTypes'; -import { scaleCalcNiceReal } from '../../coord/axisNiceTicks'; +import { scaleCalcNiceDirectly } from '../../coord/axisNiceTicks'; const PI = Math.PI; @@ -353,7 +353,7 @@ class SliderTimelineView extends TimelineView { const dataExtent = data.getDataExtent('value'); scale.setExtent(dataExtent[0], dataExtent[1]); - scaleCalcNiceReal(scale, {fixMinMax: [true, true]}); + scaleCalcNiceDirectly(scale, {fixMinMax: [true, true]}); const axis = new TimelineAxis('value', scale, layoutInfo.axisExtent as [number, number], axisType); axis.model = timelineModel; diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index fc871006af..75f2d8d55e 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -19,31 +19,27 @@ import { getAcceptableTickPrecision, - getPrecision, mathAbs, mathCeil, mathFloor, mathMax, mathRound, nice, NICE_MODE_MIN, quantity, round } from '../util/number'; import IntervalScale from '../scale/Interval'; -import { adoptScaleExtentOptionAndPrepare } from './axisHelper'; +import { adoptScaleExtentOptionAndPrepare, updateIntervalOrLogScaleForNiceOrAligned } from './axisHelper'; import { AxisBaseModel } from './AxisBaseModel'; import LogScale from '../scale/Log'; import { warn } from '../util/log'; import { increaseInterval, isLogScale, getIntervalPrecision, intervalScaleEnsureValidExtent, - logScaleLogTickPair, + logScaleLogTick, } from '../scale/helper'; import { assert } from 'zrender/src/core/util'; -import { NullUndefined } from '../util/types'; export function alignScaleTicks( targetScale: IntervalScale | LogScale, - targetDataExtent: number[], targetAxisModel: AxisBaseModel, alignToScale: IntervalScale | LogScale ): void { const isTargetLogScale = isLogScale(targetScale); const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.linearStub : alignToScale; - const targetScaleLinear = isTargetLogScale ? targetScale.linearStub : targetScale; const targetLogScaleBase = (targetScale as LogScale).base; const alignToTicks = alignToScaleLinear.getTicks(); @@ -127,7 +123,7 @@ export function alignScaleTicks( assert(alignToNiceSegCount >= 1); } - const targetExtentInfo = adoptScaleExtentOptionAndPrepare(targetScale, targetAxisModel, targetDataExtent); + const targetExtentInfo = adoptScaleExtentOptionAndPrepare(targetScale, targetAxisModel); // NOTE: // Consider a case: @@ -160,20 +156,19 @@ export function alignScaleTicks( // `12000, 9000, 6000, 3000, 0` ("nice") let targetRawExtent = [targetExtentInfo.min, targetExtentInfo.max]; + const targetRawPowExtent = targetRawExtent; if (isTargetLogScale) { - targetRawExtent = logScaleLogTickPair(targetRawExtent, targetLogScaleBase); + targetRawExtent = [ + logScaleLogTick(targetRawExtent[0], targetLogScaleBase, false), + logScaleLogTick(targetRawExtent[1], targetLogScaleBase, false) + ]; } const targetExtent = intervalScaleEnsureValidExtent(targetRawExtent, targetMinMaxFixed); - const targetMinMaxChanged = [ - targetExtent[0] !== targetRawExtent[0], - targetExtent[1] !== targetRawExtent[1] - ]; let min: number; let max: number; let interval: number; let intervalPrecision: number; - let intervalCount: number | NullUndefined; let maxNice: number; let minNice: number; @@ -225,7 +220,6 @@ export function alignScaleTicks( min = targetExtent[0]; max = targetExtent[1]; - intervalCount = alignToNiceSegCount; interval = (max - min) / (alignToNiceSegCount + t0 + t1); // Typically axis pixel extent is ready here. See `create` in `Grid.ts`. const axisPxExtent = targetAxisModel.axis.getExtent(); @@ -321,23 +315,22 @@ export function alignScaleTicks( } } - const extentPrecision = isTargetLogScale - ? [ - (targetMinMaxFixed[0] && !targetMinMaxChanged[0]) - ? getPrecision(min) : null, - (targetMinMaxFixed[1] && !targetMinMaxChanged[1]) - ? getPrecision(max) : null - ] - : []; - - // NOTE: Must in setExtent -> setConfigs order. - targetScaleLinear.setExtent(min, max); - targetScaleLinear.setConfig({ - // Even in LogScale, `interval` should not be in log space. - interval, - intervalCount, - intervalPrecision, - extentPrecision, - niceExtent: [minNice, maxNice], - }); + updateIntervalOrLogScaleForNiceOrAligned( + targetScale, + targetMinMaxFixed, + targetRawExtent, + [min, max], + targetRawPowExtent, + { + // NOTE: Even in LogScale, `interval` should not be in log space. + interval, + // Force ticks count, otherwise cumulative error may cause more unexpected ticks to be generated. + // Though the overlapping tick labels may be auto-ignored, but probably unexpected, e.g., the min + // tick label is ignored but the secondary min tick label is shown, which is unexpected when + // `axis.min` is user-specified or dataZoom-specified. + intervalCount: alignToNiceSegCount, + intervalPrecision, + niceExtent: [minNice, maxNice], + }, + ); } diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index b0771dbe5b..45e8790ae2 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -19,7 +19,7 @@ import * as zrUtil from 'zrender/src/core/util'; import OrdinalScale from '../scale/Ordinal'; -import IntervalScale from '../scale/Interval'; +import IntervalScale, { IntervalScaleConfig } from '../scale/Interval'; import Scale from '../scale/Scale'; import { prepareLayoutBarSeries, @@ -46,37 +46,28 @@ import { import CartesianAxisModel from './cartesian/AxisModel'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; -import { Dictionary, DimensionName, ScaleTick } from '../util/types'; +import { Dictionary, DimensionName, NullUndefined, ScaleTick } from '../util/types'; import { ensureScaleRawExtentInfo, ScaleRawExtentResult } from './scaleRawExtentInfo'; import { parseTimeAxisLabelFormatter } from '../util/time'; import { getScaleBreakHelper } from '../scale/break'; import { error } from '../util/log'; -import { isTimeScale } from '../scale/helper'; +import { extentDiffers, isLogScale, isOrdinalScale, isTimeScale, logScalePowTick } from '../scale/helper'; import { AxisModelExtendedInCreator } from './axisModelCreator'; +import { initExtentForUnion } from '../util/model'; type BarWidthAndOffset = ReturnType; /** - * Prepare axis scale extent before niced. + * Prepare axis scale extent before "nice". * Item of returned array can only be number (including Infinity and NaN). - * - * CAVEAT: - * This function has side-effect. - * - * FIXME: - * Refector to decouple `unionExtentFromData` and irregular value handling from `scale`. - * Merge `unionAxisExtentFromData` and `unionExtentFromData`. - * Refector `ensureScaleRawExtentInfo`. */ export function adoptScaleExtentOptionAndPrepare( scale: Scale, model: AxisBaseModel, - // Typically: data extent from all series on this axis. - // Can be obtained by `scale.unionExtentFromData(); scale.getExtent()`; - dataExtent: number[] ): ScaleRawExtentResult { - const rawExtentResult = ensureScaleRawExtentInfo(scale, model, dataExtent).calculate(); + + const rawExtentResult = ensureScaleRawExtentInfo({scale, model}).calculate(); scale.setBlank(rawExtentResult.isBlank); @@ -125,23 +116,20 @@ function adjustScaleForOverflow( model: CartesianAxisModel, // Only support cartesian coord yet. barWidthAndOffset: BarWidthAndOffset ) { - // Get Axis Length const axisExtent = model.axis.getExtent(); const axisLength = Math.abs(axisExtent[1] - axisExtent[0]); // Get bars on current base axis and calculate min and max overflow const barsOnCurrentAxis = retrieveColumnLayout(barWidthAndOffset, model.axis); - if (barsOnCurrentAxis === undefined) { + if (barsOnCurrentAxis == null) { return {min: min, max: max}; } let minOverflow = Infinity; - zrUtil.each(barsOnCurrentAxis, function (item) { - minOverflow = Math.min(item.offset, minOverflow); - }); let maxOverflow = -Infinity; zrUtil.each(barsOnCurrentAxis, function (item) { + minOverflow = Math.min(item.offset, minOverflow); maxOverflow = Math.max(item.offset + item.width, maxOverflow); }); minOverflow = Math.abs(minOverflow); @@ -180,7 +168,7 @@ export function createScaleByModel( ordinalMeta: model.getOrdinalMeta ? model.getOrdinalMeta() : model.getCategories(), - extent: [Infinity, -Infinity] + extent: initExtentForUnion() }); case 'time': return new TimeScale({ @@ -276,7 +264,8 @@ export function getAxisRawValue(axis: Axis, tick: S // In category axis with data zoom, tick is not the original // index of axis.data. So tick should not be exposed to user // in category axis. - return axis.type === 'category' ? axis.scale.getLabel(tick) : tick.value as any; + const scale = axis.scale; + return (isOrdinalScale(scale) ? scale.getLabel(tick) : tick.value) as any; } /** @@ -317,17 +306,10 @@ export function getDataDimensionsOnAxis(data: SeriesData, axisDim: string): Dime return zrUtil.keys(dataDimMap); } -/** - * FIXME: refactor - merge with `Scale#unionExtentFromData` - */ -export function unionAxisExtentFromData(dataExtent: number[], data: SeriesData, axisDim: string): void { - if (data) { - zrUtil.each(getDataDimensionsOnAxis(data, axisDim), function (dim) { - const seriesExtent = data.getApproximateExtent(dim); - seriesExtent[0] < dataExtent[0] && (dataExtent[0] = seriesExtent[0]); - seriesExtent[1] > dataExtent[1] && (dataExtent[1] = seriesExtent[1]); - }); - } +export function unionExtent(dataExtent: number[], val: number | NullUndefined): void { + // Considered that number could be NaN and should not write into the extent. + val < dataExtent[0] && (dataExtent[0] = val); + val > dataExtent[1] && (dataExtent[1] = val); } export function isNameLocationCenter(nameLocation: AxisBaseOptionCommon['nameLocation']) { @@ -364,3 +346,37 @@ function isSupportAxisBreak(axis: Axis): boolean { return (axis.dim === 'x' || axis.dim === 'y' || axis.dim === 'z' || axis.dim === 'single') && axis.type !== 'category'; } + +export function updateIntervalOrLogScaleForNiceOrAligned( + scale: IntervalScale | LogScale, + fixMinMax: boolean[], + originalLinearExtent: number[], + newLinearExtent: number[], + originalPowExtent: number[], + cfg: IntervalScaleConfig +): void { + const isTargetLogScale = isLogScale(scale); + const linearStub = isTargetLogScale ? scale.linearStub : scale; + linearStub.setExtent(newLinearExtent[0], newLinearExtent[1]); + if (isTargetLogScale) { + // Sync linearStub extent to powStub. + const powStub = scale.powStub; + let minPow = logScalePowTick(newLinearExtent[0], scale.base, null, null); + let maxPow = logScalePowTick(newLinearExtent[1], scale.base, null, null); + // Log transform is probably not inversible by rounding error, which causes min/max tick may be + // displayed as `5.999999999999999` unexpectedly when min/max are required to be fixed (specified + // by users or by dataZoom). Therefore we set `powStub` with the `originalPowExtent`. But we remain + // linearStub unchanged to avoid breaking its monotonicity between niceExtent and extent, since + // `originalPowExtent` is almost the same as `pow(originalLinearExtent)` here. + const extentChanged = extentDiffers(originalLinearExtent, newLinearExtent); + // NOTE: extent may still be changed even when min/max are required fixed, e.g., in invalid case. + if (fixMinMax[0] && !extentChanged[0]) { + minPow = originalPowExtent[0]; + } + if (fixMinMax[1] && !extentChanged[1]) { + maxPow = originalPowExtent[1]; + } + powStub.setExtent(minPow, maxPow); + } + linearStub.setConfig(cfg); +} diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts index ce1f8bfe59..caf6898afb 100644 --- a/src/coord/axisNiceTicks.ts +++ b/src/coord/axisNiceTicks.ts @@ -21,16 +21,17 @@ import { assert, noop } from 'zrender/src/core/util'; import { ensureValidSplitNumber, getIntervalPrecision, intervalScaleEnsureValidExtent, - isIntervalScale, isTimeScale + isIntervalScale, isLogScale, isTimeScale, } from '../scale/helper'; -import IntervalScale from '../scale/Interval'; -import { getPrecision, mathCeil, mathFloor, mathMax, nice, quantity, round } from '../util/number'; +import IntervalScale, { IntervalScaleConfigParsed } from '../scale/Interval'; +import { mathCeil, mathFloor, mathMax, nice, quantity, round } from '../util/number'; import type { AxisBaseModel } from './AxisBaseModel'; -import type { AxisScaleType, LogAxisBaseOption } from './axisCommonTypes'; -import { adoptScaleExtentOptionAndPrepare, retrieveAxisBreaksOption } from './axisHelper'; +import type { AxisScaleType, NumericAxisBaseOptionCommon } from './axisCommonTypes'; +import { + adoptScaleExtentOptionAndPrepare, retrieveAxisBreaksOption, updateIntervalOrLogScaleForNiceOrAligned +} from './axisHelper'; import { timeScaleCalcNice } from '../scale/Time'; import type LogScale from '../scale/Log'; -import { NullUndefined } from '../util/types'; import Scale from '../scale/Scale'; @@ -40,59 +41,54 @@ type LinearIntervalScaleStubCalcNiceTicks = ( scale: IntervalScale, opt: Pick ) => { - intervalPrecision: number; - interval: number; - niceExtent: number[]; + interval: IntervalScaleConfigParsed['interval']; + intervalPrecision: IntervalScaleConfigParsed['intervalPrecision']; + niceExtent: IntervalScaleConfigParsed['niceExtent']; }; -type LinearIntervalScaleStubCalcExtentPrecision = ( - oldExtent: number[], - newExtent: number[], - opt: Pick -) => ( - (number | NullUndefined)[] | NullUndefined -); - -function linearIntervalScaleStubCalcNice( - linearIntervalScaleStub: IntervalScale, +function intervalLogScaleCalcNice( + scale: IntervalScale | LogScale, opt: ScaleCalcNiceMethodOpt, - opt2: { - calcNiceTicks: LinearIntervalScaleStubCalcNiceTicks; - calcExtentPrecision: LinearIntervalScaleStubCalcExtentPrecision; - } ): void { // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. + const isTargetLogScale = isLogScale(scale); + const linearStub = isTargetLogScale ? scale.linearStub : scale; + const fixMinMax = opt.fixMinMax || []; - const oldExtent = linearIntervalScaleStub.getExtent(); + const oldPowExtent = isTargetLogScale ? scale.getExtent() : null; + const oldLinearExtent = linearStub.getExtent(); - let extent = intervalScaleEnsureValidExtent(oldExtent.slice(), fixMinMax); + let newLinearExtent = intervalScaleEnsureValidExtent(oldLinearExtent, fixMinMax); - linearIntervalScaleStub.setExtent(extent[0], extent[1]); - extent = linearIntervalScaleStub.getExtent(); + linearStub.setExtent(newLinearExtent[0], newLinearExtent[1]); + newLinearExtent = linearStub.getExtent(); - const { - interval, - intervalPrecision, - niceExtent, - } = opt2.calcNiceTicks(linearIntervalScaleStub, opt); + let config: ReturnType; + if (isTargetLogScale) { + config = logScaleCalcNiceTicks(linearStub, opt); + } + else { + config = intervalScaleCalcNiceTicks(linearStub, opt); + } + const interval = config.interval; + const intervalPrecision = config.intervalPrecision; if (!fixMinMax[0]) { - extent[0] = round(mathFloor(extent[0] / interval) * interval, intervalPrecision); + newLinearExtent[0] = round(mathFloor(newLinearExtent[0] / interval) * interval, intervalPrecision); } if (!fixMinMax[1]) { - extent[1] = round(mathCeil(extent[1] / interval) * interval, intervalPrecision); + newLinearExtent[1] = round(mathCeil(newLinearExtent[1] / interval) * interval, intervalPrecision); } - const extentPrecision = opt2.calcExtentPrecision(oldExtent, extent, opt); - - linearIntervalScaleStub.setExtent(extent[0], extent[1]); - linearIntervalScaleStub.setConfig({ - interval, - intervalPrecision, - niceExtent, - extentPrecision - }); + updateIntervalOrLogScaleForNiceOrAligned( + scale, + fixMinMax, + oldLinearExtent, + newLinearExtent, + oldPowExtent, + config, + ); } // ------ END: LinearIntervalScaleStub Nice ------ @@ -129,15 +125,6 @@ const intervalScaleCalcNiceTicks: LinearIntervalScaleStubCalcNiceTicks = functio return {interval, intervalPrecision, niceExtent}; }; -const intervalScaleCalcNice: ScaleCalcNiceMethod = function ( - scale: IntervalScale, opt -) { - linearIntervalScaleStubCalcNice(scale, opt, { - calcNiceTicks: intervalScaleCalcNiceTicks, - calcExtentPrecision: noop as unknown as LinearIntervalScaleStubCalcExtentPrecision, - }); -}; - // ------ END: IntervalScale Nice ------ @@ -176,25 +163,6 @@ const logScaleCalcNiceTicks: LinearIntervalScaleStubCalcNiceTicks = function ( return {intervalPrecision, interval, niceExtent}; }; -const logScaleCalcExtentPrecision: LinearIntervalScaleStubCalcExtentPrecision = function ( - oldExtent, newExtent, opt -) { - return [ - (opt.fixMinMax && opt.fixMinMax[0] && oldExtent[0] === newExtent[0]) - ? getPrecision(newExtent[0]) : null, - (opt.fixMinMax && opt.fixMinMax[1] && oldExtent[1] === newExtent[1]) - ? getPrecision(newExtent[1]) : null - ]; -}; - -const logScaleCalcNice: ScaleCalcNiceMethod = function (scale: LogScale, opt): void { - // NOTE: Calculate nice only on linearStub of LogScale. - linearIntervalScaleStubCalcNice(scale.linearStub, opt, { - calcNiceTicks: logScaleCalcNiceTicks, - calcExtentPrecision: logScaleCalcExtentPrecision, - }); -}; - // ------ END: LogScale Nice ------ @@ -219,16 +187,13 @@ type ScaleCalcNiceMethodOpt = { fixMinMax?: boolean[]; }; -export function scaleCalcNice( +export function scaleCalcNice(opt: { scale: Scale, - // scale: Scale, - inModel: AxisBaseModel, - // Typically: data extent from all series on this axis, which can be obtained by - // `scale.unionExtentFromData(...); scale.getExtent();`. - dataExtent: number[], -): void { - const model = inModel as AxisBaseModel; - const extentInfo = adoptScaleExtentOptionAndPrepare(scale, model, dataExtent); + model: AxisBaseModel, +}): void { + const scale = opt.scale; + const model = opt.model as AxisBaseModel; + const extentInfo = adoptScaleExtentOptionAndPrepare(scale, model); const isInterval = isIntervalScale(scale); const isIntervalOrTime = isInterval || isTimeScale(scale); @@ -236,7 +201,7 @@ export function scaleCalcNice( scale.setBreaksFromOption(retrieveAxisBreaksOption(model)); scale.setExtent(extentInfo.min, extentInfo.max); - scaleCalcNiceReal(scale, { + scaleCalcNiceDirectly(scale, { splitNumber: model.get('splitNumber'), fixMinMax: [extentInfo.minFixed, extentInfo.maxFixed], minInterval: isIntervalOrTime ? model.get('minInterval') : null, @@ -255,7 +220,7 @@ export function scaleCalcNice( } } -export function scaleCalcNiceReal( +export function scaleCalcNiceDirectly( scale: ScaleForCalcNice, opt: ScaleCalcNiceMethodOpt ): void { @@ -263,8 +228,8 @@ export function scaleCalcNiceReal( } const scaleCalcNiceMethods: Record = { - interval: intervalScaleCalcNice, - log: logScaleCalcNice, + interval: intervalLogScaleCalcNice, + log: intervalLogScaleCalcNice, time: timeScaleCalcNice, ordinal: noop, }; diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 3d7b067c5c..ed85e57e95 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -28,7 +28,6 @@ import {BoxLayoutReferenceResult, createBoxLayoutReference, getLayoutRect, Layou import { createScaleByModel, ifAxisCrossZero, - getDataDimensionsOnAxis, isNameLocationCenter, shouldAxisShow, retrieveAxisBreaksOption, @@ -45,17 +44,14 @@ import ExtensionAPI from '../../core/ExtensionAPI'; import { Dictionary } from 'zrender/src/core/types'; import {CoordinateSystemMaster} from '../CoordinateSystem'; import { NullUndefined, ScaleDataValue } from '../../util/types'; -import SeriesData from '../../data/SeriesData'; -import OrdinalScale from '../../scale/Ordinal'; import { findAxisModels, createCartesianAxisViewCommonPartBuilder, updateCartesianAxisViewCommonPartBuilder, - isCartesian2DInjectedAsDataCoordSys } from './cartesianAxisHelper'; import { CategoryAxisBaseOption, NumericAxisBaseOptionCommon } from '../axisCommonTypes'; import { AxisBaseModel } from '../AxisBaseModel'; -import { isIntervalOrLogScale } from '../../scale/helper'; +import { isIntervalOrLogScale, isOrdinalScale } from '../../scale/helper'; import { alignScaleTicks } from '../axisAlignTicks'; import IntervalScale from '../../scale/Interval'; import LogScale from '../../scale/Log'; @@ -71,6 +67,12 @@ import { AxisTickLabelComputingKind } from '../axisTickLabelBuilder'; import { injectCoordSysByOption } from '../../core/CoordinateSystem'; import { mathMax, parsePositionSizeOption } from '../../util/number'; import { scaleCalcNice } from '../axisNiceTicks'; +import { createDimNameMap } from '../../data/helper/SeriesDataSchema'; +import type Axis from '../Axis'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, axisExtentInfoFinalBuild, axisExtentInfoRequireBuild +} from '../scaleRawExtentInfo'; + type Cartesian2DDimensionName = 'x' | 'y'; @@ -108,6 +110,7 @@ class Grid implements CoordinateSystemMaster { // For deciding which dimensions to use when creating list data static dimensions = cartesian2DDimensions; readonly dimensions = cartesian2DDimensions; + static dimIdxMap = createDimNameMap(cartesian2DDimensions); constructor(gridModel: GridModel, ecModel: GlobalModel, api: ExtensionAPI) { this._initCartesian(gridModel, ecModel, api); @@ -122,7 +125,13 @@ class Grid implements CoordinateSystemMaster { const axesMap = this._axesMap; - this._updateScale(ecModel, this.model); + each(this._axesList, function (axis) { + axisExtentInfoFinalBuild(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + const scale = axis.scale; + if (isOrdinalScale(scale)) { + scale.setSortInfo(axis.model.get('categorySortInfo')); + } + }); function updateAxisTicks(axes: Record) { // Axis is added in order of axisIndex. @@ -135,17 +144,16 @@ class Grid implements CoordinateSystemMaster { axisNeedsAlign.push(axis); } else { - scaleCalcNice(axis.scale, axis.model, axis.scale.getExtent()); + scaleCalcNice(axis); } }; each(axisNeedsAlign, axis => { if (incapableOfAlignNeedFallback(axis, axis.alignTo as Axis2D)) { - scaleCalcNice(axis.scale, axis.model, axis.scale.getExtent()); + scaleCalcNice(axis); } else { alignScaleTicks( axis.scale as IntervalScale | LogScale, - axis.scale.getExtent(), axis.model, axis.alignTo.scale as IntervalScale | LogScale ); @@ -248,13 +256,13 @@ class Grid implements CoordinateSystemMaster { layoutRef ); // console.timeEnd('buildAxesView_determine'); - } // End of beforeDataProcessing - each(this._coordsList, function (coord) { - // Calculate affine matrix to accelerate the data to point transform. - // If all the axes scales are time or value. - coord.calcAffineTransform(); - }); + each(this._coordsList, function (coord) { + // Calculate affine matrix to accelerate the data to point transform. + // If all the axes scales are time or value. + coord.calcAffineTransform(); + }); + } // End of beforeDataProcessing } getAxis(dim: Cartesian2DDimensionName, axisIndex?: number): Axis2D { @@ -491,53 +499,6 @@ class Grid implements CoordinateSystemMaster { } } - /** - * Update cartesian properties from series. - */ - private _updateScale(ecModel: GlobalModel, gridModel: GridModel): void { - // Reset scale - each(this._axesList, function (axis) { - axis.scale.setExtent(Infinity, -Infinity); - if (axis.type === 'category') { - const categorySortInfo = axis.model.get('categorySortInfo'); - (axis.scale as OrdinalScale).setSortInfo(categorySortInfo); - } - }); - - ecModel.eachSeries(function (seriesModel) { - // If pie (or other similar series) use cartesian2d, the unionExtent logic below is - // wrong, therefore skip it temporarily. See also in `defaultAxisExtentFromData.ts`. - // TODO: support union extent in this case. - if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { - const axesModelMap = findAxisModels(seriesModel); - const xAxisModel = axesModelMap.xAxisModel; - const yAxisModel = axesModelMap.yAxisModel; - - if (!isAxisUsedInTheGrid(xAxisModel, gridModel) - || !isAxisUsedInTheGrid(yAxisModel, gridModel) - ) { - return; - } - - const cartesian = this.getCartesian( - xAxisModel.componentIndex, yAxisModel.componentIndex - ); - const data = seriesModel.getData(); - const xAxis = cartesian.getAxis('x'); - const yAxis = cartesian.getAxis('y'); - - unionExtent(data, xAxis); - unionExtent(data, yAxis); - } - }, this); - - function unionExtent(data: SeriesData, axis: Axis2D): void { - each(getDataDimensionsOnAxis(data, axis.dim), function (dim) { - axis.scale.unionExtentFromData(data, dim); - }); - } - } - /** * @param dim 'x' or 'y' or 'auto' or null/undefined */ @@ -575,6 +536,9 @@ class Grid implements CoordinateSystemMaster { // Inject the coordinateSystems into seriesModel ecModel.eachSeries(function (seriesModel) { + let xAxis: Axis; + let yAxis: Axis; + injectCoordSysByOption({ targetModel: seriesModel, coordSysType: 'cartesian2d', @@ -585,6 +549,8 @@ class Grid implements CoordinateSystemMaster { const axesModelMap = findAxisModels(seriesModel); const xAxisModel = axesModelMap.xAxisModel; const yAxisModel = axesModelMap.yAxisModel; + xAxis = xAxisModel.axis; + yAxis = yAxisModel.axis; const gridModel = xAxisModel.getCoordSysModel(); @@ -609,7 +575,12 @@ class Grid implements CoordinateSystemMaster { xAxisModel.componentIndex, yAxisModel.componentIndex ); } - }); + if (xAxis && yAxis) { + axisExtentInfoRequireBuild(xAxis, seriesModel, Grid.dimIdxMap); + axisExtentInfoRequireBuild(yAxis, seriesModel, Grid.dimIdxMap); + } + + }, this); return grids; } @@ -963,7 +934,7 @@ function createOrUpdateAxesView( function prepareOuterBounds( gridModel: GridModel, - rawRridRect: BoundingRect, + rawGridRect: BoundingRect, layoutRef: BoxLayoutReferenceResult, ): { outerBoundsRect: BoundingRect | NullUndefined @@ -973,7 +944,7 @@ function prepareOuterBounds( let outerBoundsRect: BoundingRect | NullUndefined; const optionOuterBoundsMode = gridModel.get('outerBoundsMode', true); if (optionOuterBoundsMode === 'same') { - outerBoundsRect = rawRridRect.clone(); + outerBoundsRect = rawGridRect.clone(); } else if (optionOuterBoundsMode == null || optionOuterBoundsMode === 'auto') { outerBoundsRect = getLayoutRect( @@ -1003,10 +974,10 @@ function prepareOuterBounds( const outerBoundsClamp = [ parsePositionSizeOption( - retrieve2(gridModel.get('outerBoundsClampWidth', true), OUTER_BOUNDS_CLAMP_DEFAULT[0]), rawRridRect.width + retrieve2(gridModel.get('outerBoundsClampWidth', true), OUTER_BOUNDS_CLAMP_DEFAULT[0]), rawGridRect.width ), parsePositionSizeOption( - retrieve2(gridModel.get('outerBoundsClampHeight', true), OUTER_BOUNDS_CLAMP_DEFAULT[1]), rawRridRect.height + retrieve2(gridModel.get('outerBoundsClampHeight', true), OUTER_BOUNDS_CLAMP_DEFAULT[1]), rawGridRect.height ) ]; diff --git a/src/coord/cartesian/defaultAxisExtentFromData.ts b/src/coord/cartesian/defaultAxisExtentFromData.ts index a2c78eeebf..a9d4950fa8 100644 --- a/src/coord/cartesian/defaultAxisExtentFromData.ts +++ b/src/coord/cartesian/defaultAxisExtentFromData.ts @@ -23,30 +23,59 @@ import SeriesModel from '../../model/Series'; import { isCartesian2DDeclaredSeries, findAxisModels, isCartesian2DInjectedAsDataCoordSys } from './cartesianAxisHelper'; -import { getDataDimensionsOnAxis, unionAxisExtentFromData } from '../axisHelper'; +import { getDataDimensionsOnAxis, unionExtent } from '../axisHelper'; import { AxisBaseModel } from '../AxisBaseModel'; -import Axis from '../Axis'; +import type Axis from '../Axis'; import GlobalModel from '../../model/Global'; import { Dictionary } from '../../util/types'; -import { ScaleRawExtentInfo, ScaleRawExtentResult, ensureScaleRawExtentInfo } from '../scaleRawExtentInfo'; - - -type AxisRecord = { - condExtent: number[]; - rawExtentInfo?: ScaleRawExtentInfo; - rawExtentResult?: ScaleRawExtentResult - tarExtent?: number[]; -}; - -type SeriesRecord = { - seriesModel: SeriesModel; - xAxisModel: AxisBaseModel; - yAxisModel: AxisBaseModel; -}; +import { + AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, ensureScaleRawExtentInfo, ScaleRawExtentInfo, ScaleRawExtentResult +} from '../scaleRawExtentInfo'; +import { initExtentForUnion } from '../../util/model'; -// A tricky: the priority is just after dataZoom processor. -// If dataZoom has fixed the min/max, this processor do not need to work. -// TODO: SELF REGISTERED. +/** + * @obsolete + * PENDING: + * - This file is not used anywhere currently. + * - This is a similar behavior to `dataZoom`, but historically supported separately. + * Can it be merged into `dataZoom`? + * - The impl need to be fixed, @see #15050 , and, + * - Remove side-effect. + * - Need to fix the case: + * series_a => + * x_m (category): dataExtent: [3,8] + * y_i: + * series_b => + * x_m (category): dataExtent: [4,6] + * y_j: + * series_c => + * x_m (category): dataExtent: [5,7] + * y_j: + * dataZoom control y_i, so series_a is excluded. + * So x_m.condExtent = [4,6] U [5,7] = [4,7] , and use it to call ensureScaleRawExtentInfo. + * (incorrect?, supposed to be [3,8]?) + * + * See test case `test/axis-filter-extent.html`. + * + * The responsibility of this processor: + * Enable category axis to use the specified `min`/`max` to shrink the extent of the orthogonal axis in + * Cartesian2D. That is, if some data item on a category axis is out of the range of `min`/`max`, the + * extent of the orthogonal axis will exclude the data items. + * A typical case is bar-racing, where bars are sorted dynamically and may only need to + * displayed part of the whole bars. + * + * IMPL_MEMO: + * - For each triple xAxis-yAxis-series, if either xAxis or yAxis is controlled by a dataZoom, + * the triple should be ignored in this processor. + * - Input: + * - Cartesian series data ("series approximate extent" has been prepared). + * - Axis original `ScaleRawExtentInfo` + * (the content comes from ec option and "series approximate extent"). + * - Modify(result): + * - `ScaleRawExtentInfo#min/max` of the determined "target axis". + * - "series approximate extent". + */ +// The priority is just after dataZoom processor. echarts.registerProcessor(echarts.PRIORITY.PROCESSOR.FILTER + 10, { getTargetSeries: function (ecModel) { @@ -67,6 +96,18 @@ echarts.registerProcessor(echarts.PRIORITY.PROCESSOR.FILTER + 10, { } }); +type AxisRecord = { + rawExtentInfo?: ScaleRawExtentInfo; + rawExtentResult?: ScaleRawExtentResult; + tarExtent?: number[]; +}; + +type SeriesRecord = { + seriesModel: SeriesModel; + xAxisModel: AxisBaseModel; + yAxisModel: AxisBaseModel; +}; + function prepareDataExtentOnAxis( ecModel: GlobalModel, axisRecordMap: HashMap, @@ -87,15 +128,14 @@ function prepareDataExtentOnAxis( const yAxisModel = axesModelMap.yAxisModel; const xAxis = xAxisModel.axis; const yAxis = yAxisModel.axis; - const xRawExtentInfo = xAxis.scale.rawExtentInfo; - const yRawExtentInfo = yAxis.scale.rawExtentInfo; - const data = seriesModel.getData(); + const xRawExtentInfo = ensureScaleRawExtentInfo(xAxis); + const yRawExtentInfo = ensureScaleRawExtentInfo(yAxis); // If either axis controlled by other filter like "dataZoom", // use the rule of dataZoom rather than adopting the rules here. if ( - (xRawExtentInfo && xRawExtentInfo.frozen) - || (yRawExtentInfo && yRawExtentInfo.frozen) + (xRawExtentInfo && xRawExtentInfo.from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM) + || (yRawExtentInfo && yRawExtentInfo.from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM) ) { return; } @@ -105,12 +145,6 @@ function prepareDataExtentOnAxis( xAxisModel: xAxisModel, yAxisModel: yAxisModel }); - - // FIXME: this logic needs to be consistent with - // `coord/cartesian/Grid.ts#_updateScale`. - // It's not good to implement one logic in multiple places. - unionAxisExtentFromData(prepareAxisRecord(axisRecordMap, xAxisModel).condExtent, data, xAxis.dim); - unionAxisExtentFromData(prepareAxisRecord(axisRecordMap, yAxisModel).condExtent, data, yAxis.dim); }); } @@ -125,24 +159,16 @@ function calculateFilteredExtent( const yAxis = yAxisModel.axis; const xAxisRecord = prepareAxisRecord(axisRecordMap, xAxisModel); const yAxisRecord = prepareAxisRecord(axisRecordMap, yAxisModel); - xAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo( - xAxis.scale, xAxisModel, xAxisRecord.condExtent - ); - yAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo( - yAxis.scale, yAxisModel, yAxisRecord.condExtent - ); + xAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo(xAxis); + yAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo(yAxis); xAxisRecord.rawExtentResult = xAxisRecord.rawExtentInfo.calculate(); yAxisRecord.rawExtentResult = yAxisRecord.rawExtentInfo.calculate(); - // If the "xAxis" is set `min`/`max`, some data items might be out of the cartesian. - // then the "yAxis" may needs to calculate extent only based on the data items inside - // the cartesian (similar to what "dataZoom" did). - // A typical case is bar-racing, where bars ara sort dynamically and may only need to - // displayed part of the whole bars. - const data = seriesRecord.seriesModel.getData(); // For duplication removal. + // key: series data dimension corresponding to the condition axis. const condDimMap: Dictionary = {}; + // key: series data dimension corresponding to the target axis. const tarDimMap: Dictionary = {}; let condAxis: Axis; let tarAxisRecord: AxisRecord; @@ -150,10 +176,9 @@ function calculateFilteredExtent( function addCondition(axis: Axis, axisRecord: AxisRecord) { // But for simplicity and safety and performance, we only adopt this // feature on category axis at present. - const condExtent = axisRecord.condExtent; const rawExtentResult = axisRecord.rawExtentResult; if (axis.type === 'category' - && (condExtent[0] < rawExtentResult.min || rawExtentResult.max < condExtent[1]) + && (rawExtentResult.dataMin < rawExtentResult.min || rawExtentResult.max < rawExtentResult.dataMax) ) { each(getDataDimensionsOnAxis(data, axis.dim), function (dataDim) { if (!hasOwn(condDimMap, dataDim)) { @@ -185,7 +210,7 @@ function calculateFilteredExtent( const condDims = keys(condDimMap); const tarDims = keys(tarDimMap); const tarDimExtents = map(tarDims, function () { - return initExtent(); + return initExtentForUnion(); }); const condDimsLen = condDims.length; @@ -204,7 +229,7 @@ function calculateFilteredExtent( if (singleCondDim && singleTarDim) { for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { const condVal = data.get(singleCondDim, dataIdx) as number; - if (condAxis.scale.isInExtent(condVal)) { + if (condAxis.scale.contain(condVal)) { unionExtent(tarDimExtents[0], data.get(singleTarDim, dataIdx) as number); } } @@ -213,7 +238,7 @@ function calculateFilteredExtent( for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { for (let j = 0; j < condDimsLen; j++) { const condVal = data.get(condDims[j], dataIdx) as number; - if (condAxis.scale.isInExtent(condVal)) { + if (condAxis.scale.contain(condVal)) { for (let k = 0; k < tarDimsLen; k++) { unionExtent(tarDimExtents[k], data.get(tarDims[k], dataIdx) as number); } @@ -225,10 +250,9 @@ function calculateFilteredExtent( } each(tarDimExtents, function (tarDimExtent, i) { - const dim = tarDims[i]; // FIXME: if there has been approximateExtent set? - data.setApproximateExtent(tarDimExtent as [number, number], dim); - const tarAxisExtent = tarAxisRecord.tarExtent = tarAxisRecord.tarExtent || initExtent(); + data.setApproximateExtent(tarDimExtent as [number, number], tarDims[i]); + const tarAxisExtent = tarAxisRecord.tarExtent = tarAxisRecord.tarExtent || initExtentForUnion(); unionExtent(tarAxisExtent, tarDimExtent[0]); unionExtent(tarAxisExtent, tarDimExtent[1]); }); @@ -240,13 +264,13 @@ function shrinkAxisExtent(axisRecordMap: HashMap) { const tarAxisExtent = axisRecord.tarExtent; if (tarAxisExtent) { const rawExtentResult = axisRecord.rawExtentResult; - const rawExtentInfo = axisRecord.rawExtentInfo; + // const rawExtentInfo = axisRecord.rawExtentInfo; // Shrink the original extent. if (!rawExtentResult.minFixed && tarAxisExtent[0] > rawExtentResult.min) { - rawExtentInfo.modifyDataMinMax('min', tarAxisExtent[0]); + // rawExtentInfo.modifyDataMinMax('min', tarAxisExtent[0]); } if (!rawExtentResult.maxFixed && tarAxisExtent[1] < rawExtentResult.max) { - rawExtentInfo.modifyDataMinMax('max', tarAxisExtent[1]); + // rawExtentInfo.modifyDataMinMax('max', tarAxisExtent[1]); } } }); @@ -257,14 +281,5 @@ function prepareAxisRecord( axisModel: AxisBaseModel ): AxisRecord { return axisRecordMap.get(axisModel.uid) - || axisRecordMap.set(axisModel.uid, { condExtent: initExtent() }); -} - -function initExtent() { - return [Infinity, -Infinity]; -} - -function unionExtent(extent: number[], val: number) { - val < extent[0] && (extent[0] = val); - val > extent[1] && (extent[1] = val); + || axisRecordMap.set(axisModel.uid, {}); } diff --git a/src/coord/geo/geoCreator.ts b/src/coord/geo/geoCreator.ts index d41f72b545..fdf77947c6 100644 --- a/src/coord/geo/geoCreator.ts +++ b/src/coord/geo/geoCreator.ts @@ -96,8 +96,8 @@ function resizeGeo(this: Geo, geoModel: ComponentModel(); + +export const AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE = 1; +export const AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM = 2; +const AXIS_EXTENT_INFO_BUILD_FROM_EMPTY = 3; +export type AxisExtentInfoBuildFrom = + typeof AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE + | typeof AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM + | typeof AXIS_EXTENT_INFO_BUILD_FROM_EMPTY; export interface ScaleRawExtentResult { // `min`/`max` defines data available range, determined by @@ -38,6 +60,9 @@ export interface ScaleRawExtentResult { min: number; max: number; + dataMin: number; + dataMax: number; + // `minFixed`/`maxFixed` is `true` iff: // - ec option `xxxAxis.min/max` are specified, or // - `scaleRawExtentResult.minDetermined/maxDetermined` are `true` @@ -80,13 +105,13 @@ export class ScaleRawExtentInfo { private _determinedMin: number; private _determinedMax: number; - // Make that the `rawExtentInfo` can not be modified any more. - readonly frozen: boolean; - // custom dataMin/dataMax private _dataMinNum: number; private _dataMaxNum: number; + // Injected outside + readonly from: AxisExtentInfoBuildFrom; + constructor( scale: Scale, model: AxisBaseModel, @@ -187,9 +212,9 @@ export class ScaleRawExtentInfo { /** * Calculate extent by prepared parameters. - * This method has no external dependency and can be called duplicatedly, + * This method has no external dependency and can be called repeatedly, * getting the same result. - * If parameters changed, should call this method to recalcuate. + * If parameters changed, should call this method to recalculate. */ calculate(): ScaleRawExtentResult { // Notice: When min/max is not set (that is, when there are null/undefined, @@ -293,6 +318,8 @@ export class ScaleRawExtentInfo { return { min: min, max: max, + dataMin: dataMin, + dataMax: dataMax, minFixed: minFixed, maxFixed: maxFixed, minDetermined: minDetermined, @@ -302,74 +329,177 @@ export class ScaleRawExtentInfo { }; } - modifyDataMinMax(minMaxName: 'min' | 'max', val: number): void { - if (__DEV__) { - assert(!this.frozen); - } - this[DATA_MIN_MAX_ATTR[minMaxName]] = val; - } + // modifyDataMinMax(minMaxName: 'min' | 'max', val: number): void { + // this[DATA_MIN_MAX_ATTR[minMaxName]] = val; + // } setDeterminedMinMax(minMaxName: 'min' | 'max', val: number): void { const attr = DETERMINED_MIN_MAX_ATTR[minMaxName]; if (__DEV__) { - assert( - !this.frozen - // Earse them usually means logic flaw. - && (this[attr] == null) - ); + assert(this[attr] == null); } this[attr] = val; } - - freeze() { - // @ts-ignore - this.frozen = true; - } } const DETERMINED_MIN_MAX_ATTR = { min: '_determinedMin', max: '_determinedMax' } as const; -const DATA_MIN_MAX_ATTR = { min: '_dataMin', max: '_dataMax' } as const; +// const DATA_MIN_MAX_ATTR = { min: '_dataMin', max: '_dataMax' } as const; + +function parseAxisModelMinMax(scale: Scale, minMax: ScaleDataValue): number { + return minMax == null ? null + : eqNaN(minMax) ? NaN + : scale.parse(minMax); +} /** - * Get scale min max and related info only depends on model settings. - * This method can be called after coordinate system created. - * For example, in data processing stage. + * @usage + * class SomeCoordSys { + * static create() { + * ecModel.eachSeries(function (seriesModel) { + * axisExtentInfoRequireBuild(axis1, seriesModel, ...); + * axisExtentInfoRequireBuild(axis2, seriesModel, ...); + * // ... + * }); + * } + * update() { + * axisExtentInfoFinalBuild(axis1); + * axisExtentInfoFinalBuild(axis2); + * } + * } + * class AxisProxy { + * reset() { + * axisExtentInfoFinalBuild(axis1); + * } + * } * - * Scale extent info probably be required multiple times during a workflow. - * For example: - * (1) `dataZoom` depends it to get the axis extent in "100%" state. - * (2) `processor/extentCalculator` depends it to make sure whether axis extent is specified. - * (3) `coordSys.update` use it to finally decide the scale extent. - * But the callback of `min`/`max` should not be called multiple times. - * The code below should not be implemented repeatedly either. - * So we cache the result in the scale instance, which will be recreated at the beginning - * of the workflow (because `scale` instance will be recreated each round of the workflow). + * NOTICE: + * - `axisExtentInfoRequireBuild` should be typically called in: + * - Coord sys create method. + * - `axisExtentInfoFinalBuild` should be typically called in: + * - `dataZoom` processor. It require processing like: + * 1. Filter series data by dataZoom1; + * 2. Union the filtered data and init the extent of the orthogonal axes, which is the 100% of dataZoom2; + * 3. Filter series data by dataZoom2; + * 4. ... + * - Coord sys update method, for other axes that not covered by `dataZoom`. + * NOTE: If `dataZoom` exists can covers this series, this data and its extent + * has been dataZoom-filtered. Therefore this handling should not before dataZoom. + * - The callback of `min`/`max` in ec option should NOT be called multiple times, + * therefore, we initialize `ScaleRawExtentInfo` uniformly in `axisExtentInfoFinalBuild`. */ -export function ensureScaleRawExtentInfo( - scale: Scale, - model: AxisBaseModel, - // Typically: data extent from all series on this axis. - // FIXME: - // Refactor: only the first input `dataExtent` is used but it is determined by the - // caller, which is error-prone. - dataExtent: number[] -): ScaleRawExtentInfo { +export function axisExtentInfoRequireBuild( + axis: Axis, + seriesModel: SeriesModel, + // coordSysDimIdxMap is required only for `boxCoordinateSystem`. + coordSysDimIdxMap: HashMap | NullUndefined +): void { + const axisStore = axisSeriesInner(axis); + if (!axisStore.extent) { + axisStore.extent = initExtentForUnion(); + axisStore.seriesList = []; + } + axisStore.seriesList.push(seriesModel); + if (seriesModel.boxCoordinateSystem) { + // This supports union extent on case like: pie (or other similar series) + // lays out on cartesian2d. + if (__DEV__) { + assert(coordSysDimIdxMap); + } + axisStore.dimIdxInCoord = coordSysDimIdxMap.get(axis.dim); + if (__DEV__) { + assert(axisStore.dimIdxInCoord >= 0); + } + } +} - // Do not permit to recreate. - let rawExtentInfo = scale.rawExtentInfo; - if (rawExtentInfo) { - return rawExtentInfo; +/** + * @see {axisExtentInfoRequireBuild} + */ +export function axisExtentInfoFinalBuild( + ecModel: GlobalModel, + axis: Axis, + from: AxisExtentInfoBuildFrom +): void { + const scale = axis.scale; + const axisStore = axisSeriesInner(axis); + const extent = axisStore.extent; + + if (scale.rawExtentInfo) { + if (__DEV__) { + // Check for incorrect impl - the duplicated calling of this method is only allowed in + // one case that first dataZoom than coord sys update. + assert(scale.rawExtentInfo.from !== from); + } + return; } - rawExtentInfo = new ScaleRawExtentInfo(scale, model, dataExtent); - // @ts-ignore - scale.rawExtentInfo = rawExtentInfo; + each(axisStore.seriesList, function (seriesModel) { + // Legend-filtered series need to be ignored since series are registered before `legendFilter`. + if (ecModel.isSeriesFiltered(seriesModel)) { + return; + } + if (seriesModel.boxCoordinateSystem) { + // This supports union extent on case like: pie (or other similar series) + // lays out on cartesian2d. + const {coord} = getCoordForCoordSysUsageKindBox(seriesModel); + let val: number | NullUndefined; + const dimIdx = axisStore.dimIdxInCoord; + // Only `[val1, val2]` case needs to be supported currently. + if (isArray(coord)) { + const coordItem = coord[dimIdx]; + if (coordItem != null && !isArray(coordItem)) { + val = axis.scale.parse(coordItem); + unionExtent(extent, val); + } + } + } + else if (seriesModel.coordinateSystem) { + // NOTE: This data may have been filtered by dataZoom on orthogonal axes. + const data = seriesModel.getData(); + if (data) { + each(getDataDimensionsOnAxis(data, axis.dim), function (dim) { + const seriesExtent = data.getApproximateExtent(dim); + unionExtent(extent, seriesExtent[0]); + unionExtent(extent, seriesExtent[1]); + }); + } + } + + }); + + const rawExtentInfo = new ScaleRawExtentInfo(scale, axis.model, extent); + injectScaleRawExtentInfo(scale, rawExtentInfo, from); - return rawExtentInfo; + // PENDING: Is it necessary? See `adoptScaleExtentOptionAndPrepare`, + // need scale extent in `makeColumnLayout`. + const result = rawExtentInfo.calculate(); + scale.setExtent(result.min, result.max); + + axisStore.seriesList = axisStore.extent = null; // Clean up } -export function parseAxisModelMinMax(scale: Scale, minMax: ScaleDataValue): number { - return minMax == null ? null - : eqNaN(minMax) ? NaN - : scale.parse(minMax); +export function ensureScaleRawExtentInfo( + {scale, model}: {scale: Scale; model: AxisBaseModel;} +): ScaleRawExtentInfo { + if (!scale.rawExtentInfo) { + // `rawExtentInfo` may not be created in cases such as no series declared or extra useless + // axes declared in ec option. In this case we still create a default one for that empty axis. + injectScaleRawExtentInfo( + scale, + new ScaleRawExtentInfo(scale, model, initExtentForUnion()), + AXIS_EXTENT_INFO_BUILD_FROM_EMPTY + ); + } + return scale.rawExtentInfo; +} + +function injectScaleRawExtentInfo( + scale: Scale, + scaleRawExtentInfo: ScaleRawExtentInfo, + from: AxisExtentInfoBuildFrom +): void { + // @ts-ignore + scale.rawExtentInfo = scaleRawExtentInfo; + // @ts-ignore + scaleRawExtentInfo.from = from; } diff --git a/src/coord/single/Single.ts b/src/coord/single/Single.ts index eb1e2f50b3..62730946b8 100644 --- a/src/coord/single/Single.ts +++ b/src/coord/single/Single.ts @@ -24,7 +24,6 @@ import SingleAxis from './SingleAxis'; import * as axisHelper from '../axisHelper'; import {createBoxLayoutReference, getLayoutRect} from '../../util/layout'; -import {each} from 'zrender/src/core/util'; import { CoordinateSystem, CoordinateSystemMaster } from '../CoordinateSystem'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; @@ -35,6 +34,9 @@ import { ScaleDataValue } from '../../util/types'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; import { scaleCalcNice } from '../axisNiceTicks'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, axisExtentInfoFinalBuild +} from '../scaleRawExtentInfo'; export const singleDimensions = ['single']; /** @@ -97,17 +99,9 @@ class Single implements CoordinateSystem, CoordinateSystemMaster { * Update axis scale after data processed */ update(ecModel: GlobalModel, api: ExtensionAPI) { - ecModel.eachSeries(function (seriesModel) { - if (seriesModel.coordinateSystem === this) { - const data = seriesModel.getData(); - const axis = this._axis; - const scale = axis.scale; - each(data.mapDimensionsAll(this.dimension), function (dim) { - scale.unionExtentFromData(data, dim); - }); - scaleCalcNice(scale, axis.model, scale.getExtent()); - } - }, this); + const axis = this._axis; + axisExtentInfoFinalBuild(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleCalcNice(axis); } /** diff --git a/src/coord/single/singleCreator.ts b/src/coord/single/singleCreator.ts index 1c2c5849a0..3271c6931e 100644 --- a/src/coord/single/singleCreator.ts +++ b/src/coord/single/singleCreator.ts @@ -28,6 +28,7 @@ import SingleAxisModel from './AxisModel'; import SeriesModel from '../../model/Series'; import { SeriesOption } from '../../util/types'; import { SINGLE_REFERRING } from '../../util/model'; +import {axisExtentInfoRequireBuild} from '../scaleRawExtentInfo'; /** * Create single coordinate system and inject it into seriesModel. @@ -53,7 +54,10 @@ function create(ecModel: GlobalModel, api: ExtensionAPI) { const singleAxisModel = seriesModel.getReferringComponents( 'singleAxis', SINGLE_REFERRING ).models[0] as SingleAxisModel; - seriesModel.coordinateSystem = singleAxisModel && singleAxisModel.coordinateSystem; + const single = seriesModel.coordinateSystem = singleAxisModel && singleAxisModel.coordinateSystem; + if (single) { + axisExtentInfoRequireBuild(single.getAxis(), seriesModel, null); + } } }); diff --git a/src/core/CoordinateSystem.ts b/src/core/CoordinateSystem.ts index d3535347e3..5979adc85b 100644 --- a/src/core/CoordinateSystem.ts +++ b/src/core/CoordinateSystem.ts @@ -33,8 +33,8 @@ type CoordinateSystemCreatorMap = {[type: string]: CoordinateSystemCreator}; /** * FIXME: * `nonSeriesBoxCoordSysCreators` and `_nonSeriesBoxMasterList` are hardcoded implementations. - * Regarding "coord sys layout based on another coord sys", currently we only exprimentally support one level - * dpendency, such as, "grid(cartesian)s can be laid out based on matrix/calendar coord sys." + * Regarding "coord sys layout based on another coord sys", currently we only experimentally support one level + * dependency, such as, "grid(cartesian)s can be laid out based on matrix/calendar coord sys." * But a comprehensive implementation may need to support: * - Recursive dependencies. e.g., a matrix coord sys lays out based on another matrix coord sys. * That requires in the implementation `create` and `update` of coord sys are called by a dependency graph. @@ -109,18 +109,16 @@ function canBeNonSeriesBoxCoordSys(coordSysType: string): boolean { return !!nonSeriesBoxCoordSysCreators[coordSysType]; } - -export const BoxCoordinateSystemCoordFrom = { - // By default fetch coord from `model.get('coord')`. - coord: 1, - // Some model/series, such as pie, is allowed to also get coord from `model.get('center')`, - // if cannot get from `model.get('coord')`. But historically pie use `center` option, but - // geo use `layoutCenter` option to specify layout center; they are not able to be unified. - // Therefor it is not recommended. - coord2: 2, -} as const; +// By default fetch coord from `model.get('coord')`. +export const BOX_COORD_SYS_COORD_FROM_PROP_COORD = 1 as const; +// Some model/series, such as pie, is allowed to also get coord from `model.get('center')`, +// if cannot get from `model.get('coord')`. But historically pie use `center` option, but +// geo use `layoutCenter` option to specify layout center; they are not able to be unified. +// Therefor it is not recommended. +export const BOX_COORD_SYS_COORD_FROM_PROP_COORD2 = 2 as const; export type BoxCoordinateSystemCoordFrom = - (typeof BoxCoordinateSystemCoordFrom)[keyof typeof BoxCoordinateSystemCoordFrom]; + typeof BOX_COORD_SYS_COORD_FROM_PROP_COORD + | typeof BOX_COORD_SYS_COORD_FROM_PROP_COORD2; type BoxCoordinateSystemGetCoord2 = (model: ComponentModel) => CoordinateSystemDataCoord; @@ -147,18 +145,18 @@ const coordSysUseMap = zrUtil.createHashMap< /** * @return Be an object, but never be NullUndefined. */ -export function getCoordForBoxCoordSys( +export function getCoordForCoordSysUsageKindBox( model: ComponentModel ): { coord: CoordinateSystemDataCoord | NullUndefined from: BoxCoordinateSystemCoordFrom } { let coord: CoordinateSystemDataCoord = model.getShallow('coord', true); - let from: BoxCoordinateSystemCoordFrom = BoxCoordinateSystemCoordFrom.coord; + let from: BoxCoordinateSystemCoordFrom = BOX_COORD_SYS_COORD_FROM_PROP_COORD; if (coord == null) { const store = coordSysUseMap.get(model.type); if (store && store.getCoord2) { - from = BoxCoordinateSystemCoordFrom.coord2; + from = BOX_COORD_SYS_COORD_FROM_PROP_COORD2; coord = store.getCoord2(model); } } @@ -166,22 +164,23 @@ export function getCoordForBoxCoordSys( } /** - * - "dataCoordSys": each data item is laid out based on a coord sys. - * - "boxCoordSys": the overall bounding rect or anchor point is calculated based on a coord sys. + * - `COORD_SYS_USAGE_KIND_DATA`: each data item is laid out based on a coord sys. + * - `COORD_SYS_USAGE_KIND_BOX`: the overall bounding rect or anchor point is calculated based on a coord sys. * e.g., * grid rect (cartesian rect) is calculate based on matrix/calendar coord sys; * pie center is calculated based on calendar/cartesian; * * The default value (if not declared in option `coordinateSystemUsage`): - * For series, use `dataCoordSys`, since this is the most case and backward compatible. - * For non-series components, use `boxCoordSys`, since `dataCoordSys` is not applicable. + * For series, use `COORD_SYS_USAGE_KIND_DATA`, since this is the most common case and backward compatible. + * For non-series components, use `COORD_SYS_USAGE_KIND_BOX`, since `COORD_SYS_USAGE_KIND_DATA` is not applicable. */ -export const CoordinateSystemUsageKind = { - none: 0, - dataCoordSys: 1, - boxCoordSys: 2, -} as const; -export type CoordinateSystemUsageKind = (typeof CoordinateSystemUsageKind)[keyof typeof CoordinateSystemUsageKind]; +export const COORD_SYS_USAGE_KIND_NONE = 0 as const; +export const COORD_SYS_USAGE_KIND_DATA = 1 as const; +export const COORD_SYS_USAGE_KIND_BOX = 2 as const; +export type CoordinateSystemUsageKind = + typeof COORD_SYS_USAGE_KIND_NONE + | typeof COORD_SYS_USAGE_KIND_DATA + | typeof COORD_SYS_USAGE_KIND_BOX; export function decideCoordSysUsageKind( // Component or series @@ -195,7 +194,7 @@ export function decideCoordSysUsageKind( const coordSysType = model.getShallow('coordinateSystem'); let coordSysUsageOption = model.getShallow('coordinateSystemUsage', true); const isDeclaredExplicitly = coordSysUsageOption != null; - let kind: CoordinateSystemUsageKind = CoordinateSystemUsageKind.none; + let kind: CoordinateSystemUsageKind = COORD_SYS_USAGE_KIND_NONE; if (coordSysType) { const isSeries = model.mainType === 'series'; @@ -204,18 +203,18 @@ export function decideCoordSysUsageKind( } if (coordSysUsageOption === 'data') { - kind = CoordinateSystemUsageKind.dataCoordSys; + kind = COORD_SYS_USAGE_KIND_DATA; if (!isSeries) { if (__DEV__) { if (isDeclaredExplicitly && printError) { error('coordinateSystemUsage "data" is not supported in non-series components.'); } } - kind = CoordinateSystemUsageKind.none; + kind = COORD_SYS_USAGE_KIND_NONE; } } else if (coordSysUsageOption === 'box') { - kind = CoordinateSystemUsageKind.boxCoordSys; + kind = COORD_SYS_USAGE_KIND_BOX; if (!isSeries && !canBeNonSeriesBoxCoordSys(coordSysType)) { if (__DEV__) { if (isDeclaredExplicitly && printError) { @@ -224,7 +223,7 @@ export function decideCoordSysUsageKind( ); } } - kind = CoordinateSystemUsageKind.none; + kind = COORD_SYS_USAGE_KIND_NONE; } } } @@ -234,40 +233,50 @@ export function decideCoordSysUsageKind( /** * These cases are considered: - * (A) Most series can use only "dataCoordSys", but "boxCoordSys" is not applicable: + * (A) Most series can use only "COORD_SYS_USAGE_KIND_DATA", but "COORD_SYS_USAGE_KIND_BOX" is not applicable: * - e.g., series.heatmap, series.line, series.bar, series.scatter, ... - * (B) Some series and most components can use only "boxCoordSys", but "dataCoordSys" is not applicable: + * (B) Some series and most components can use only "COORD_SYS_USAGE_KIND_BOX", but "COORD_SYS_USAGE_KIND_DATA" + * is not applicable: * - e.g., series.pie, series.funnel, ... * - e.g., grid, polar, geo, title, ... - * (C) Several series can use both "boxCoordSys" and "dataCoordSys", even at the same time: + * (C) Several series can use both "COORD_SYS_USAGE_KIND_BOX" and "COORD_SYS_USAGE_KIND_DATA", even at the same time: * - e.g., series.graph, series.map - * - If graph or map series use a "boxCoordSys", it creates a internal "dataCoordSys" to lay out its data. - * - Graph series can use matrix coord sys as either the "dataCoordSys" (each item layout on one cell) - * or "boxCoordSys" (the entire series are layout within one cell). + * - If graph or map series use "COORD_SYS_USAGE_KIND_BOX", it creates a internal coord sys as + * "COORD_SYS_USAGE_KIND_DATA" to lay out its data. + * - Graph series can use matrix coord sys as either the "COORD_SYS_USAGE_KIND_DATA" (each item layout + * on one cell) or "COORD_SYS_USAGE_KIND_BOX" (the entire series are layout within one cell). * - To achieve this effect, * `series.coordinateSystemUsage: 'box'` needs to be specified explicitly. * * Check these echarts option settings: * - If `series: {type: 'bar'}`: - * dataCoordSys: "cartesian2d", boxCoordSys: "none". + * COORD_SYS_USAGE_KIND_DATA: "cartesian2d", + * COORD_SYS_USAGE_KIND_BOX: "none". * (since `coordinateSystem: 'cartesian2d'` is the default option in bar.) * - If `grid: {coordinateSystem: 'matrix'}` - * dataCoordSys: "none", boxCoordSys: "matrix". + * COORD_SYS_USAGE_KIND_DATA: "none", + * COORD_SYS_USAGE_KIND_BOX: "matrix". * - If `series: {type: 'pie', coordinateSystem: 'matrix'}`: - * dataCoordSys: "none", boxCoordSys: "matrix". + * COORD_SYS_USAGE_KIND_DATA: "none", + * COORD_SYS_USAGE_KIND_BOX: "matrix". * (since `coordinateSystemUsage: 'box'` is the default option in pie.) * - If `series: {type: 'graph', coordinateSystem: 'matrix'}`: - * dataCoordSys: "matrix", boxCoordSys: "none" + * COORD_SYS_USAGE_KIND_DATA: "matrix", + * COORD_SYS_USAGE_KIND_BOX: "none" * - If `series: {type: 'graph', coordinateSystem: 'matrix', coordinateSystemUsage: 'box'}`: - * dataCoordSys: "an internal view", boxCoordSys: "the internal view is laid out on a matrix" + * COORD_SYS_USAGE_KIND_DATA: "an internal view", + * COORD_SYS_USAGE_KIND_BOX: "the internal view is laid out on a matrix" * - If `series: {type: 'map'}`: - * dataCoordSys: "a internal geo", boxCoordSys: "none" + * COORD_SYS_USAGE_KIND_DATA: "a internal geo", + * COORD_SYS_USAGE_KIND_BOX: "none" * - If `series: {type: 'map', coordinateSystem: 'geo', geoIndex: 0}`: - * dataCoordSys: "a geo", boxCoordSys: "none" + * COORD_SYS_USAGE_KIND_DATA: "a geo", + * COORD_SYS_USAGE_KIND_BOX: "none" * - If `series: {type: 'map', coordinateSystem: 'matrix'}`: * not_applicable * - If `series: {type: 'map', coordinateSystem: 'matrix', coordinateSystemUsage: 'box'}`: - * dataCoordSys: "an internal geo", boxCoordSys: "the internal geo is laid out on a matrix" + * COORD_SYS_USAGE_KIND_DATA: "an internal geo", + * COORD_SYS_USAGE_KIND_BOX: "the internal geo is laid out on a matrix" * * @usage * For case (A) & (B), @@ -276,8 +285,6 @@ export function decideCoordSysUsageKind( * call `injectCoordSysByOption({coordSysType: 'aaa', ...})` once for each series/components, * and then call `injectCoordSysByOption({coordSysType: 'bbb', ..., isDefaultDataCoordSys: true})` * once for each series/components. - * - * @return Whether injected. */ export function injectCoordSysByOption(opt: { // series or component @@ -286,7 +293,7 @@ export function injectCoordSysByOption(opt: { coordSysProvider: CoordSysInjectionProvider; isDefaultDataCoordSys?: boolean; allowNotFound?: boolean -}): boolean { +}): CoordinateSystemUsageKind { const { targetModel, coordSysType, @@ -301,16 +308,16 @@ export function injectCoordSysByOption(opt: { let {kind, coordSysType: declaredType} = decideCoordSysUsageKind(targetModel, true); if (isDefaultDataCoordSys - && kind !== CoordinateSystemUsageKind.dataCoordSys + && kind !== COORD_SYS_USAGE_KIND_DATA ) { - // If both dataCoordSys and boxCoordSys declared in one model. + // If both `COORD_SYS_USAGE_KIND_DATA` and `COORD_SYS_USAGE_KIND_BOX` declared in one model. // There is the only case in series-graph, and no other cases yet. - kind = CoordinateSystemUsageKind.dataCoordSys; + kind = COORD_SYS_USAGE_KIND_DATA; declaredType = coordSysType; } - if (kind === CoordinateSystemUsageKind.none || declaredType !== coordSysType) { - return false; + if (kind === COORD_SYS_USAGE_KIND_NONE || declaredType !== coordSysType) { + return COORD_SYS_USAGE_KIND_NONE; } const coordSys = coordSysProvider(coordSysType, targetModel); @@ -322,20 +329,20 @@ export function injectCoordSysByOption(opt: { ); } } - return false; + return COORD_SYS_USAGE_KIND_NONE; } - if (kind === CoordinateSystemUsageKind.dataCoordSys) { + if (kind === COORD_SYS_USAGE_KIND_DATA) { if (__DEV__) { zrUtil.assert(targetModel.mainType === 'series'); } (targetModel as SeriesModel).coordinateSystem = coordSys; } - else { // kind === 'boxCoordSys' + else { // kind === COORD_SYS_USAGE_KIND_BOX targetModel.boxCoordinateSystem = coordSys; } - return true; + return kind; } type CoordSysInjectionProvider = ( diff --git a/src/core/echarts.ts b/src/core/echarts.ts index 89f810088b..b8f60ebcc6 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -153,12 +153,15 @@ export const dependencies = { const TEST_FRAME_REMAIN_TIME = 1; const PRIORITY_PROCESSOR_SERIES_FILTER = 800; -// Some data processors depends on the stack result dimension (to calculate data extent). -// So data stack stage should be in front of data processing stage. +// In the current impl, "data stack" will modifies the original "series data extent". Some data +// processors rely on the stack result dimension to calculate extents. So data stack +// should be in front of other data processors. const PRIORITY_PROCESSOR_DATASTACK = 900; -// "Data filter" will block the stream, so it should be -// put at the beginning of data processing. +// `PRIORITY_PROCESSOR_FILTER` is typically used by `dataZoom` (see `AxisProxy`), which relies +// on the initialized "axis extent". const PRIORITY_PROCESSOR_FILTER = 1000; +// NOTICE: These "data processors" (especially, data filters) above may block the stream, so they +// should be put at the beginning of data processing. const PRIORITY_PROCESSOR_DEFAULT = 2000; const PRIORITY_PROCESSOR_STATISTIC = 5000; @@ -168,7 +171,7 @@ const PRIORITY_VISUAL_GLOBAL = 2000; const PRIORITY_VISUAL_CHART = 3000; const PRIORITY_VISUAL_COMPONENT = 4000; // Visual property in data. Greater than `PRIORITY_VISUAL_COMPONENT` to enable to -// overwrite the viusal result of component (like `visualMap`) +// overwrite the visual result of component (like `visualMap`) // using data item specific setting (like itemStyle.xxx on data item) const PRIORITY_VISUAL_CHART_DATA_CUSTOM = 4500; // Greater than `PRIORITY_VISUAL_CHART_DATA_CUSTOM` to enable to layout based on @@ -180,8 +183,8 @@ const PRIORITY_VISUAL_DECAL = 7000; export const PRIORITY = { PROCESSOR: { - FILTER: PRIORITY_PROCESSOR_FILTER, SERIES_FILTER: PRIORITY_PROCESSOR_SERIES_FILTER, + FILTER: PRIORITY_PROCESSOR_FILTER, STATISTIC: PRIORITY_PROCESSOR_STATISTIC }, VISUAL: { diff --git a/src/data/DataStore.ts b/src/data/DataStore.ts index 9bcc88e9a7..4a3a0031e3 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -29,6 +29,7 @@ import { DataProvider } from './helper/dataProvider'; import { parseDataValue } from './helper/dataValueHelper'; import OrdinalMeta from './OrdinalMeta'; import { shouldRetrieveDataByName, Source } from './Source'; +import { initExtentForUnion } from '../util/model'; const UNDEFINED = 'undefined'; /* global Float64Array, Int32Array, Uint32Array, Uint16Array */ @@ -115,9 +116,7 @@ function getIndicesCtor(rawCount: number): DataArrayLikeConstructor { // The possible max value in this._indicies is always this._rawCount despite of filtering. return rawCount > 65535 ? CtorUint32Array : CtorUint16Array; }; -function getInitialExtent(): [number, number] { - return [Infinity, -Infinity]; -}; + function cloneChunk(originalChunk: DataValueChunk): DataValueChunk { const Ctor = originalChunk.constructor; // Only shallow clone is enough when Array. @@ -263,7 +262,7 @@ class DataStore { calcDimNameToIdx.set(dimName, calcDimIdx); this._chunks[calcDimIdx] = new dataCtors[type || 'float'](this._rawCount); - this._rawExtent[calcDimIdx] = getInitialExtent(); + this._rawExtent[calcDimIdx] = initExtentForUnion(); return calcDimIdx; } @@ -282,7 +281,7 @@ class DataStore { if (offset === 0) { // We need to reset the rawExtent if collect is from start. // Because this dimension may be guessed as number and calcuating a wrong extent. - rawExtents[dimIdx] = getInitialExtent(); + rawExtents[dimIdx] = initExtentForUnion(); } const dimRawExtent = rawExtents[dimIdx]; @@ -386,7 +385,7 @@ class DataStore { for (let i = 0; i < dimLen; i++) { const dim = dimensions[i]; if (!rawExtent[i]) { - rawExtent[i] = getInitialExtent(); + rawExtent[i] = initExtentForUnion(); } prepareStore(chunks, i, dim.type, end, append); } @@ -596,7 +595,7 @@ class DataStore { } /** - * Data filter. + * [NOTICE]: Performance-sensitive for large data. */ filter( dims: DimensionIndex[], @@ -817,7 +816,7 @@ class DataStore { const rawExtent = target._rawExtent; for (let i = 0; i < dims.length; i++) { - rawExtent[dims[i]] = getInitialExtent(); + rawExtent[dims[i]] = initExtentForUnion(); } for (let dataIndex = 0; dataIndex < dataCount; dataIndex++) { @@ -1044,7 +1043,7 @@ class DataStore { const dimStore = targetStorage[dimension]; const len = this.count(); - const rawExtentOnDim = target._rawExtent[dimension] = getInitialExtent(); + const rawExtentOnDim = target._rawExtent[dimension] = initExtentForUnion(); const newIndices = new (getIndicesCtor(this._rawCount))(Math.ceil(len / frameSize)); @@ -1133,7 +1132,7 @@ class DataStore { getDataExtent(dim: DimensionIndex): [number, number] { // Make sure use concrete dim as cache name. const dimData = this._chunks[dim]; - const initialExtent = getInitialExtent(); + const initialExtent = initExtentForUnion(); if (!dimData) { return initialExtent; diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index 472d3b3c7e..f18aec6b73 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -676,14 +676,6 @@ class SeriesData< } /** - * PENDING: In fact currently this function is only used to short-circuit - * the calling of `scale.unionExtentFromData` when data have been filtered by modules - * like "dataZoom". `scale.unionExtentFromData` is used to calculate data extent for series on - * an axis, but if a "axis related data filter module" is used, the extent of the axis have - * been fixed and no need to calling `scale.unionExtentFromData` actually. - * But if we add "custom data filter" in future, which is not "axis related", this method may - * be still needed. - * * Optimize for the scenario that data is filtered by a given extent. * Consider that if data amount is more than hundreds of thousand, * extent calculation will cost more than 10ms and the cache will diff --git a/src/data/helper/dataStackHelper.ts b/src/data/helper/dataStackHelper.ts index 549820c912..172d686b0d 100644 --- a/src/data/helper/dataStackHelper.ts +++ b/src/data/helper/dataStackHelper.ts @@ -123,9 +123,6 @@ export function enableDataStack( byIndex = true; } - // Add stack dimension, they can be both calculated by coordinate system in `unionExtent`. - // That put stack logic in List is for using conveniently in echarts extensions, but it - // might not be a good way. if (stackedDimInfo) { // Use a weird name that not duplicated with other names. // Also need to use seriesModel.id as postfix because different diff --git a/src/export/api/helper.ts b/src/export/api/helper.ts index 803c0f6740..d1ca41b8fa 100644 --- a/src/export/api/helper.ts +++ b/src/export/api/helper.ts @@ -97,7 +97,7 @@ export function createScale(dataExtent: number[], option: object | AxisBaseModel const scale = axisHelper.createScaleByModel(axisModel as AxisBaseModel); scale.setExtent(dataExtent[0], dataExtent[1]); - scaleCalcNice(scale, axisModel as AxisBaseModel, scale.getExtent()); + scaleCalcNice({scale, model: axisModel as AxisBaseModel}); return scale; } diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index cb979216fd..747f468e2b 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -422,23 +422,14 @@ function doCalBarWidthAndOffset(seriesInfoList: LayoutSeriesInfo[]) { * @param seriesModel If not provided, return all. * @return {stackId: {offset, width}} or {offset, width} if seriesModel provided. */ -function retrieveColumnLayout(barWidthAndOffset: BarWidthAndOffset, axis: Axis2D): typeof barWidthAndOffset[string]; -// eslint-disable-next-line max-len -function retrieveColumnLayout(barWidthAndOffset: BarWidthAndOffset, axis: Axis2D, seriesModel: BarSeriesModel): typeof barWidthAndOffset[string][string]; -function retrieveColumnLayout( +export function retrieveColumnLayout( barWidthAndOffset: BarWidthAndOffset, - axis: Axis2D, - seriesModel?: BarSeriesModel -) { + axis: Axis2D +): BarWidthAndOffset[keyof BarWidthAndOffset] { if (barWidthAndOffset && axis) { - const result = barWidthAndOffset[getAxisKey(axis)]; - if (result != null && seriesModel != null) { - return result[getSeriesStackId(seriesModel)]; - } - return result; + return barWidthAndOffset[getAxisKey(axis)]; } } -export {retrieveColumnLayout}; export function layout(seriesType: string, ecModel: GlobalModel) { diff --git a/src/model/Global.ts b/src/model/Global.ts index c1e2be3250..33b92ba3f3 100644 --- a/src/model/Global.ts +++ b/src/model/Global.ts @@ -184,7 +184,7 @@ class GlobalModel extends Model { * Key: seriesIndex. * Keep consistent with `_seriesIndices`. */ - private _seriesIndicesMap: HashMap; + private _seriesIndicesMap: HashMap; /** * Model for store update payload @@ -810,9 +810,6 @@ echarts.use([${seriesImportName}]);`); /** * Iterate raw series before filtered. - * - * @param {Function} cb - * @param {*} context */ eachRawSeries( cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => void, @@ -874,7 +871,7 @@ echarts.use([${seriesImportName}]);`); }, this); this._seriesIndices = newSeriesIndices; - this._seriesIndicesMap = createHashMap(newSeriesIndices); + this._seriesIndicesMap = createHashMap(newSeriesIndices); } restoreData(payload?: Payload): void { @@ -915,7 +912,7 @@ echarts.use([${seriesImportName}]);`); // series may have been removed by `replaceMerge`. series && seriesIndices.push(series.componentIndex); }); - ecModel._seriesIndicesMap = createHashMap(seriesIndices); + ecModel._seriesIndicesMap = createHashMap(seriesIndices); }; assertSeriesInitialized = function (ecModel: GlobalModel): void { diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index 78babe18be..d26ac43b5a 100644 --- a/src/scale/Interval.ts +++ b/src/scale/Interval.ts @@ -21,31 +21,26 @@ import {round, mathRound, mathMin, getPrecision} from '../util/number'; import {addCommas} from '../util/format'; import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; -import * as helper from './helper'; +import { contain, getIntervalPrecision, IntervalScaleGetLabelOpt } from './helper'; import {ScaleTick, ScaleDataValue, NullUndefined} from '../util/types'; import { getScaleBreakHelper } from './break'; import { assert, clone } from 'zrender/src/core/util'; import { getMinorTicks } from './minorTicks'; -type IntervalScaleConfig = { +export type IntervalScaleConfig = { interval: IntervalScaleConfigParsed['interval']; intervalPrecision?: IntervalScaleConfigParsed['intervalPrecision'] | NullUndefined; - extentPrecision?: IntervalScaleConfigParsed['extentPrecision'] | NullUndefined; intervalCount?: IntervalScaleConfigParsed['intervalCount'] | NullUndefined; niceExtent?: IntervalScaleConfigParsed['niceExtent'] | NullUndefined; }; -type IntervalScaleConfigParsed = { +export type IntervalScaleConfigParsed = { /** * Step of ticks. */ interval: number; intervalPrecision: number; - /** - * Precisions of `_extent[0]` and `_extent[1]`. - */ - extentPrecision: (number | NullUndefined)[]; /** * `_intervalCount` effectively specifies the number of "nice segments". This is for special cases, * such as `alignTicks: true` and min max are fixed. In this case, `_interval` may be specified with @@ -86,7 +81,6 @@ class IntervalScale e this._cfg = { interval: 0, intervalPrecision: 2, - extentPrecision: [], intervalCount: undefined, niceExtent: undefined, }; @@ -120,7 +114,7 @@ class IntervalScale e } contain(val: number): boolean { - return helper.contain(val, this._extent); + return contain(val, this._extent); } normalize(val: number): number { @@ -166,9 +160,8 @@ class IntervalScale e cfg.niceExtent = extent.slice() as [number, number]; } if (cfg.intervalPrecision == null) { - cfg.intervalPrecision = helper.getIntervalPrecision(cfg.interval); + cfg.intervalPrecision = getIntervalPrecision(cfg.interval); } - cfg.extentPrecision = cfg.extentPrecision || []; } /** @@ -323,7 +316,7 @@ class IntervalScale e */ getLabel( data: ScaleTick, - opt?: helper.IntervalScaleGetLabelOpt + opt?: IntervalScaleGetLabelOpt ): string { if (data == null) { return ''; diff --git a/src/scale/Log.ts b/src/scale/Log.ts index 5481003bf7..0ce0b55d99 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -19,25 +19,21 @@ import * as zrUtil from 'zrender/src/core/util'; import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; -import { - mathPow, mathLog, -} from '../util/number'; // Use some method of IntervalScale import IntervalScale from './Interval'; import { - DimensionLoose, DimensionName, AxisBreakOption, + AxisBreakOption, ScaleTick, NullUndefined, ScaleDataValue } from '../util/types'; import { - logScalePowTickPair, logScalePowTick, logScaleLogTickPair, - getExtentPrecision, + logScalePowTick, IntervalScaleGetLabelOpt, + contain, logScaleLogTick, } from './helper'; -import SeriesData from '../data/SeriesData'; import { getScaleBreakHelper } from './break'; import { getMinorTicks } from './minorTicks'; @@ -49,44 +45,49 @@ class LogScale extends Scale { readonly base: number; - // `_originalScale` is used to save some original info (before logarithm - // applied, such as raw extent; but may be still invalid, and not sync - // to the calculated ("nice") extent). - private _originalScale: IntervalScale; - // `linearStub` provides linear tick arrangement (logarithm applied). + /** + * `powStub` is used to save original values, i.e., values before logarithm + * applied, such as raw extent and raw breaks. + * NOTE: Logarithm transform is probably not inversible by rounding error, which + * may cause min/max tick is displayed like `5.999999999999999`. The extent in + * powStub is used to get the original precise extent for this issue. + * + * [CAVEAT] `powStub` and `linearStub` should be modified synchronously. + */ + readonly powStub: IntervalScale; + /** + * `linearStub` provides linear tick arrangement (logarithm applied). + * @see {powStub} + */ readonly linearStub: IntervalScale; constructor(logBase: number | NullUndefined, settings?: ScaleSettingDefault) { super(); - this._originalScale = new IntervalScale(); + this.powStub = new IntervalScale(); this.linearStub = new IntervalScale(settings); this.base = zrUtil.retrieve2(logBase, 10); } getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { const base = this.base; - const originalScale = this._originalScale; + const powStub = this.powStub; const scaleBreakHelper = getScaleBreakHelper(); const linearStub = this.linearStub; - const extent = linearStub.getExtent(); - const extentPrecision = linearStub.getConfig().extentPrecision; + const linearExtent = linearStub.getExtent(); + const powExtent = powStub.getExtent(); return zrUtil.map(linearStub.getTicks(opt || {}), function (tick) { const val = tick.value; - let powVal = logScalePowTick( - val, - base, - getExtentPrecision(val, extent, extentPrecision) - ); + let powVal = logScalePowTick(val, base, linearExtent, powExtent); let vBreak; if (scaleBreakHelper) { const brkPowResult = scaleBreakHelper.getTicksPowBreak( tick, base, - originalScale.innerGetBreaks(), - extent, - extentPrecision + powStub.innerGetBreaks(), + linearExtent, + powExtent, ); if (brkPowResult) { vBreak = brkPowResult.vBreak; @@ -105,7 +106,7 @@ class LogScale extends Scale { return getMinorTicks( this, splitNumber, - this._originalScale.innerGetBreaks(), + this.powStub.innerGetBreaks(), // NOTE: minor ticks are in the log scale value to visually hint users "logarithm". this.linearStub.getConfig().interval ); @@ -119,29 +120,15 @@ class LogScale extends Scale { } setExtent(start: number, end: number): void { - // [CAVEAT]: If modifying this logic, must sync to `_initLinearStub`. - this._originalScale.setExtent(start, end); - const loggedExtent = logScaleLogTickPair([start, end], this.base); - this.linearStub.setExtent(loggedExtent[0], loggedExtent[1]); - } - - getExtent() { - const linearStub = this.linearStub; - return logScalePowTickPair( - linearStub.getExtent(), - this.base, - linearStub.getConfig().extentPrecision + this.powStub.setExtent(start, end); + this.linearStub.setExtent( + logScaleLogTick(start, this.base, false), + logScaleLogTick(end, this.base, false) ); } - isInExtent(value: number): boolean { - return this.linearStub.isInExtent(logScaleLogTick(value, this.base)); - } - - unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { - this._originalScale.unionExtentFromData(data, dim); - const loggedOther = logScaleLogTickPair(data.getApproximateExtent(dim), this.base, true); - this.linearStub.innerUnionExtent(loggedOther); + getExtent() { + return this.powStub.getExtent(); } parse(val: ScaleDataValue): number { @@ -149,18 +136,17 @@ class LogScale extends Scale { } contain(val: number): boolean { - val = mathLog(val) / mathLog(this.base); - return this.linearStub.contain(val); + return contain(val, this.getExtent()); } normalize(val: number): number { - val = mathLog(val) / mathLog(this.base); - return this.linearStub.normalize(val); + return this.linearStub.normalize(logScaleLogTick(val, this.base, true)); } scale(val: number): number { - val = this.linearStub.scale(val); - return mathPow(this.base, val); + // PENDING: Input `linearStub.getExtent()` and `powStub.getExtent()` may + // break monotonicity. Do not do it until real problems found. + return logScalePowTick(this.linearStub.scale(val), this.base, null, null); } setBreaksFromOption( @@ -175,7 +161,7 @@ class LogScale extends Scale { this.base, zrUtil.bind(this.parse, this) ); - this._originalScale.innerSetBreak(parsedOriginal); + this.powStub.innerSetBreak(parsedOriginal); this.linearStub.innerSetBreak(parsedLogged); } diff --git a/src/scale/Ordinal.ts b/src/scale/Ordinal.ts index b7ab881a66..b346cc92bc 100644 --- a/src/scale/Ordinal.ts +++ b/src/scale/Ordinal.ts @@ -26,7 +26,7 @@ import Scale from './Scale'; import OrdinalMeta from '../data/OrdinalMeta'; -import * as scaleHelper from './helper'; +import { contain } from './helper'; import { OrdinalRawValue, OrdinalNumber, @@ -36,6 +36,7 @@ import { } from '../util/types'; import { CategoryAxisBaseOption } from '../coord/axisCommonTypes'; import { isArray, map, isObject, isString } from 'zrender/src/core/util'; +import { mathMin, mathRound } from '../util/number'; type OrdinalScaleSetting = { ordinalMeta?: OrdinalMeta | CategoryAxisBaseOption['data']; @@ -91,7 +92,13 @@ class OrdinalScale extends Scale { * 1, // ordinalNumber: 5, yValue: 220 * ] * ``` - * The index of this array is from `0` to `ordinalMeta.categories.length`. + * NOTICE: + * - The index of `_ordinalNumbersByTick` is "tick number", i.e., `tick.value`, + * rather than the index of `scale.getTicks()`, though commonly they are the same, + * except that the `_extent[0]` is delibrately set to be not zero. + * - Currently we only support that the index of `_ordinalNumbersByTick` is + * from `0` to `ordinalMeta.categories.length - 1`. + * - `OrdinalNumber` is always from `0` to `ordinalMeta.categories.length - 1`. * * @see `Ordinal['getRawOrdinalNumber']` * @see `OrdinalSortInfo` @@ -100,7 +107,8 @@ class OrdinalScale extends Scale { /** * This is the inverted map of `_ordinalNumbersByTick`. - * The index of this array is from `0` to `ordinalMeta.categories.length`. + * The index is `OrdinalNumber`, which is from `0` to `ordinalMeta.categories.length - 1`. + * after `_ticksByOrdinalNumber` is initialized. * * @see `Ordinal['_ordinalNumbersByTick']` * @see `Ordinal['_getTickNumber']` @@ -135,16 +143,18 @@ class OrdinalScale extends Scale { return isString(val) ? this._ordinalMeta.getOrdinal(val) // val might be float. - : Math.round(val); + : mathRound(val); } contain(val: OrdinalNumber): boolean { - return scaleHelper.contain(val, this._extent) + return contain(this._getTickNumber(val), this._extent) && val >= 0 && val < this._ordinalMeta.categories.length; } /** - * Normalize given rank or name to linear [0, 1] + * Normalize given rank or name to linear [0, 1]. + * `normalize` and `scale` are typically used to map data to pixel. + * * @param val raw ordinal number. * @return normalized value in [0, 1]. */ @@ -154,11 +164,13 @@ class OrdinalScale extends Scale { } /** + * @see {normalize} + * * @param val normalized value in [0, 1]. * @return raw ordinal number. */ scale(val: number): OrdinalNumber { - val = Math.round(this._calculator.scale(val, this._extent)); + val = mathRound(this._calculator.scale(val, this._extent)); return this.getRawOrdinalNumber(val); } @@ -198,9 +210,8 @@ class OrdinalScale extends Scale { // Unnecessary support negative tick in `realtimeSort`. let tickNum = 0; const allCategoryLen = this._ordinalMeta.categories.length; - for (const len = Math.min(allCategoryLen, infoOrdinalNumbers.length); tickNum < len; ++tickNum) { - const ordinalNumber = infoOrdinalNumbers[tickNum]; - ordinalsByTick[tickNum] = ordinalNumber; + for (const len = mathMin(allCategoryLen, infoOrdinalNumbers.length); tickNum < len; ++tickNum) { + const ordinalNumber = ordinalsByTick[tickNum] = infoOrdinalNumbers[tickNum]; ticksByOrdinal[ordinalNumber] = tickNum; } // Handle that `series.data` only covers part of the `axis.category.data`. @@ -209,7 +220,7 @@ class OrdinalScale extends Scale { while (ticksByOrdinal[unusedOrdinal] != null) { unusedOrdinal++; }; - ordinalsByTick.push(unusedOrdinal); + ordinalsByTick[tickNum] = unusedOrdinal; ticksByOrdinal[unusedOrdinal] = tickNum; } } @@ -226,8 +237,7 @@ class OrdinalScale extends Scale { /** * @usage * ```js - * const ordinalNumber = ordinalScale.getRawOrdinalNumber(tickVal); - * + * const ordinalNumber = ordinalScale.getRawOrdinalNumber(tick.value); * // case0 * const rawOrdinalValue = axisModel.getCategories()[ordinalNumber]; * // case1 @@ -236,11 +246,11 @@ class OrdinalScale extends Scale { * const coord = axis.dataToCoord(ordinalNumber); * ``` * - * @param {OrdinalNumber} tickNumber index of display + * @param tickNumber This is `scale.getTicks()[i].value`. */ getRawOrdinalNumber(tickNumber: number): OrdinalNumber { const ordinalNumbersByTick = this._ordinalNumbersByTick; - // tickNumber may be out of range, e.g., when axis max is larger than `ordinalMeta.categories.length`., + // tickNumber may be out of range, e.g., when axis max is larger than `ordinalMeta.categories.length`, // where ordinal numbers are used as tick value directly. return (ordinalNumbersByTick && tickNumber >= 0 && tickNumber < ordinalNumbersByTick.length) ? ordinalNumbersByTick[tickNumber] @@ -264,15 +274,6 @@ class OrdinalScale extends Scale { return this._extent[1] - this._extent[0] + 1; } - /** - * @override - * If value is in extent range - */ - isInExtent(value: OrdinalNumber): boolean { - value = this._getTickNumber(value); - return this._extent[0] <= value && this._extent[1] >= value; - } - getOrdinalMeta(): OrdinalMeta { return this._ordinalMeta; } diff --git a/src/scale/Scale.ts b/src/scale/Scale.ts index 3dfe36195c..1ed53433f6 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -20,11 +20,8 @@ import * as clazzUtil from '../util/clazz'; import { Dictionary } from 'zrender/src/core/types'; -import SeriesData from '../data/SeriesData'; import { - DimensionName, ScaleDataValue, - DimensionLoose, ScaleTick, AxisBreakOption, NullUndefined, @@ -37,6 +34,7 @@ import { ScaleRawExtentInfo } from '../coord/scaleRawExtentInfo'; import { bind } from 'zrender/src/core/util'; import { ScaleBreakContext, AxisBreakParsingResult, getScaleBreakHelper, ParamPruneByBreak } from './break'; import { AxisScaleType } from '../coord/axisCommonTypes'; +import { initExtentForUnion } from '../util/model'; export type ScaleGetTicksOpt = { // Whether expand the ticks to nice extent. @@ -60,7 +58,8 @@ abstract class Scale private _setting: SETTING; // [CAVEAT]: Should update only by `setExtent`! - // Make sure that extent[0] always <= extent[1]. + // The caller of `setExtent()` should ensure `extent[0] <= extent[1]`, + // but it is initialized as [Infinity, -Infinity]. protected _extent: [number, number]; // FIXME: Effectively, both logarithmic scale and break scale are numeric axis transformation @@ -75,11 +74,12 @@ abstract class Scale private _isBlank: boolean; // Inject - readonly rawExtentInfo: ScaleRawExtentInfo; + // MUST only visit by `ensureScaleRawExtentInfo()`, as it may be null/undefined. + readonly rawExtentInfo: ScaleRawExtentInfo | NullUndefined; constructor(setting?: SETTING) { this._setting = setting || {} as SETTING; - this._extent = [Infinity, -Infinity]; + this._extent = initExtentForUnion(); const scaleBreakHelper = getScaleBreakHelper(); if (scaleBreakHelper) { this._brkCtx = scaleBreakHelper.createScaleBreakContext(); @@ -118,25 +118,13 @@ abstract class Scale */ abstract scale(val: number): number; - /** - * @final NEVER override! - */ - innerUnionExtent(other: number[]): void { - const extent = this._extent; - // Considered that number could be NaN and should not write into the extent. - this.setExtent( - other[0] < extent[0] ? other[0] : extent[0], - other[1] > extent[1] ? other[1] : extent[1] - ); - } - - unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { - this.innerUnionExtent(data.getApproximateExtent(dim)); - } - /** * Get a new slice of extent. * Extent is always in increase order. + * + * [NOTICE]: + * In ec workflow, `getExtent()` is finally determined on `coordSys#update` stage, + * and `ensureScaleRawExtentInfo()` is used before `coordSys#update` stage. */ getExtent(): [number, number] { return this._extent.slice() as [number, number]; @@ -208,11 +196,6 @@ abstract class Scale : extent[1] - extent[0]; } - isInExtent(value: number): boolean { - const extent = this._extent; - return extent[0] <= value && extent[1] >= value; - } - /** * When axis extent depends on data and no data exists, * axis ticks should not be drawn, which is named 'blank'. diff --git a/src/scale/Time.ts b/src/scale/Time.ts index 5785dc16b6..6482240e85 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -74,7 +74,7 @@ import { primaryTimeUnits, roundTime } from '../util/time'; -import * as scaleHelper from './helper'; +import { contain, ensureValidSplitNumber } from './helper'; import Scale, { ScaleGetTicksOpt } from './Scale'; import {TimeScaleTick, ScaleTick, AxisBreakOption, NullUndefined} from '../util/types'; import {TimeAxisLabelFormatterParsed} from '../coord/axisCommonTypes'; @@ -128,7 +128,7 @@ class TimeScale extends Scale { /** * Get label is mainly for other components like dataZoom, tooltip. */ - getLabel(tick: TimeScaleTick): string { + getLabel(tick: ScaleTick): string { const useUTC = this.getSetting('useUTC'); return format( tick.value, @@ -278,7 +278,7 @@ class TimeScale extends Scale { } contain(val: number): boolean { - return scaleHelper.contain(val, this._extent); + return contain(val, this._extent); } normalize(val: number): number { @@ -749,7 +749,7 @@ export const timeScaleCalcNice: ScaleCalcNiceMethod = function (scale: TimeScale } scale.setExtent(extent[0], extent[1]); - const splitNumber = scaleHelper.ensureValidSplitNumber(opt.splitNumber, 10); + const splitNumber = ensureValidSplitNumber(opt.splitNumber, 10); const span = scale.getBreaksElapsedExtentSpan(); let approxInterval = span / splitNumber; diff --git a/src/scale/break.ts b/src/scale/break.ts index 2f994b6f06..bb4ca7112c 100644 --- a/src/scale/break.ts +++ b/src/scale/break.ts @@ -117,9 +117,9 @@ export type ScaleBreakHelper = { getTicksPowBreak( tick: ScaleTick, logBase: number, - logOriginalBreaks: ParsedAxisBreakList, - extent: number[], - extentPrecision: (number | NullUndefined)[], + powBreaks: ParsedAxisBreakList, + linearExtent: number[], + powExtent: number[], ): { tickPowValue: number | NullUndefined; vBreak: VisualAxisBreak | NullUndefined; diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index 8c5938f45f..9f88690350 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -17,7 +17,7 @@ * under the License. */ -import { assert, clone, each, find, isString, map, retrieve2, trim } from 'zrender/src/core/util'; +import { assert, clone, each, find, isString, map, trim } from 'zrender/src/core/util'; import { NullUndefined, ParsedAxisBreak, ParsedAxisBreakList, AxisBreakOption, AxisBreakOptionIdentifierInAxis, ScaleTick, VisualAxisBreak, @@ -25,9 +25,10 @@ import { import { error } from '../util/log'; import type Scale from './Scale'; import { ScaleBreakContext, AxisBreakParsingResult, registerScaleBreakHelperImpl, ParamPruneByBreak } from './break'; -import { getPrecision, mathMax, mathMin, mathRound } from '../util/number'; +import { mathMax, mathMin, mathRound } from '../util/number'; import { AxisLabelFormatterExtraParams } from '../coord/axisCommonTypes'; -import { getExtentPrecision, logScaleLogTick, logScaleLogTickPair, logScalePowTick } from './helper'; +import { logScaleLogTick, logScalePowTick } from './helper'; +import { initExtentForUnion } from '../util/model'; /** * @caution @@ -42,7 +43,7 @@ class ScaleBreakContextImpl implements ScaleBreakContext { // [CAVEAT]: Should update only by `ScaleBreakContext#update`! // They are the values that scaleExtent[0] and scaleExtent[1] are mapped to a numeric axis // that breaks are applied, primarily for optimization of `Scale#normalize`. - private _elapsedExtent: [number, number] = [Infinity, -Infinity]; + private _elapsedExtent: [number, number] = initExtentForUnion(); setBreaks(parsed: AxisBreakParsingResult): void { // @ts-ignore @@ -623,9 +624,9 @@ function retrieveAxisBreakPairs( function getTicksPowBreak( tick: ScaleTick, logBase: number, - logOriginalBreaks: ParsedAxisBreakList, - extent: number[], - extentPrecision: (number | NullUndefined)[], + powBreaks: ParsedAxisBreakList, + linearExtent: number[], + powExtent: number[], ): { tickPowValue: number; vBreak: VisualAxisBreak | NullUndefined; @@ -636,22 +637,18 @@ function getTicksPowBreak( } const brk = tick.break.parsedBreak; - const originalBreak = find(logOriginalBreaks, brk => identifyAxisBreak( + const powBreak = find(powBreaks, brk => identifyAxisBreak( brk.breakOption, tick.break.parsedBreak.breakOption )); - const minPrecision = getExtentPrecision(brk.vmin, extent, extentPrecision); - const maxPrecision = getExtentPrecision(brk.vmax, extent, extentPrecision); - // NOTE: `tick.break` may be clamped by scale extent. For consistency we always - // pow back, or heuristically use the user input original break to obtain an - // acceptable rounding precision for display. - const vmin = logScalePowTick(brk.vmin, logBase, retrieve2(minPrecision, getPrecision(originalBreak.vmin))); - const vmax = logScalePowTick(brk.vmax, logBase, retrieve2(maxPrecision, getPrecision(originalBreak.vmax))); + // NOTE: `tick.break` may have been clamped by scale extent. + const vmin = logScalePowTick(brk.vmin, logBase, linearExtent, powExtent); + const vmax = logScalePowTick(brk.vmax, logBase, linearExtent, powExtent); const parsedBreak = { vmin, vmax, // They are not changed by extent clamping. breakOption: brk.breakOption, - gapParsed: clone(originalBreak.gapParsed), + gapParsed: clone(powBreak.gapParsed), gapReal: brk.gapReal, }; const vBreak = { @@ -678,7 +675,8 @@ function logarithmicParseBreaksFromOption( const parsedLogged = parseAxisBreakOption(breakOptionList, parse, opt); parsedLogged.breaks = map(parsedLogged.breaks, brk => { - const [vmin, vmax] = logScaleLogTickPair([brk.vmin, brk.vmax], logBase, true); + const vmin = logScaleLogTick(brk.vmin, logBase, true); + const vmax = logScaleLogTick(brk.vmax, logBase, true); const gapParsed = { type: brk.gapParsed.type, val: brk.gapParsed.type === 'tpAbs' diff --git a/src/scale/helper.ts b/src/scale/helper.ts index b290282812..4cddbc1aa3 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -22,13 +22,14 @@ import { mathPow, mathMax, mathRound, mathLog, mathAbs, mathFloor, mathCeil } from '../util/number'; -import IntervalScale from './Interval'; -import LogScale from './Log'; +import type IntervalScale from './Interval'; +import type LogScale from './Log'; import type Scale from './Scale'; import { bind } from 'zrender/src/core/util'; import type { ScaleBreakContext } from './break'; -import TimeScale from './Time'; +import type TimeScale from './Time'; import { NullUndefined } from '../util/types'; +import type OrdinalScale from './Ordinal'; type intervalScaleNiceTicksResult = { interval: number, @@ -56,7 +57,7 @@ export type IntervalScaleGetLabelOpt = { // || f === 5; // } -export function isIntervalOrLogScale(scale: Scale): scale is LogScale | IntervalScale { +export function isIntervalOrLogScale(scale: Scale): scale is (LogScale | IntervalScale) { return isIntervalScale(scale) || isLogScale(scale); } @@ -72,7 +73,7 @@ export function isLogScale(scale: Scale): scale is LogScale { return scale.type === 'log'; } -export function isOrdinalScale(scale: Scale): boolean { +export function isOrdinalScale(scale: Scale): scale is OrdinalScale { return scale.type === 'ordinal'; } @@ -142,7 +143,7 @@ export function getIntervalPrecision(niceInterval: number): number { return getPrecision(niceInterval) + 2; } -export function contain(val: number, extent: [number, number]): boolean { +export function contain(val: number, extent: number[]): boolean { return val >= extent[0] && val <= extent[1]; } @@ -166,7 +167,7 @@ export class ScaleCalculator { function normalize( val: number, extent: [number, number], - // Dont use optional arguments for performance consideration here. + // Don't use optional arguments for performance consideration here. ): number { if (extent[1] === extent[0]) { return 0.5; @@ -182,23 +183,12 @@ function scale( } /** - * @see logScaleLogTick + * NOTE: if `val` is `NaN`, return `NaN`. */ -export function logScaleLogTickPair( - pair: number[], - base: number, - noClampNegative?: boolean -): [number, number] { - return [ - logScaleLogTick(pair[0], base, noClampNegative), - logScaleLogTick(pair[1], base, noClampNegative) - ]; -} - export function logScaleLogTick( val: number, base: number, - noClampNegative?: boolean + noClampNegative: boolean ): number { // log(negative) is NaN, so safe guard here. // PENDING: But even getting a -Infinity still does not make sense in extent. @@ -209,49 +199,31 @@ export function logScaleLogTick( // used to display. } -/** - * @see logScalePowTick - */ -export function logScalePowTickPair( - linearPair: number[], - base: number, - precisionPair: (number | NullUndefined)[], -): [number, number] { - return [ - logScalePowTick(linearPair[0], base, precisionPair[0]), - logScalePowTick(linearPair[1], base, precisionPair[1]) - ] as [number, number]; -} - /** * Cumulative rounding errors cause the logarithm operation to become non-invertible by simply exponentiation. * - `Math.pow(10, integer)` itself has no rounding error. But, * - If `linearTickVal` is generated internally by `calcNiceTicks`, it may be still "not nice" (not an integer) * when it is `extent[i]`. * - If `linearTickVal` is generated outside (e.g., by `alignScaleTicks`) and set by `setExtent`, - * `logScaleLogTickPair` may already have introduced rounding errors even for "nice" values. + * `logScaleLogTick` may already have introduced rounding errors even for "nice" values. * But invertible is required when the original `extent[i]` need to be respected, or "nice" ticks need to be - * displayed instead of something like `5.999999999999999`, which is addressed in this function by providing - * a `precision`. + * displayed instead of something like `5.999999999999999`, which is addressed in this function. * See also `#4158`. + * + * [CAUTION]: + * Monotonicity may be broken on extent ends - callers must make sure it does not matter. */ export function logScalePowTick( // `tickVal` should be in the linear space. linearTickVal: number, base: number, - precision: number | NullUndefined, + linearExtent: number[] | NullUndefined, + powExtent: number[] | NullUndefined, ): number { - - // NOTE: Even when min/max is required to be fixed, `pow(base, tickVal)` is not necessarily equal to - // `originalPowExtent[0]`/`[1]`. e.g., when `originalPowExtent` is a invalid extent but - // `tickVal` has been adjusted to make it valid. So we always use `Math.pow`. - let powVal = mathPow(base, linearTickVal); - - if (precision != null) { - powVal = round(powVal, precision); - } - - return powVal; + const hasExt = linearExtent && powExtent; + return (hasExt && linearTickVal === linearExtent[0]) ? powExtent[0] + : (hasExt && linearTickVal === linearExtent[1]) ? powExtent[1] + : mathPow(base, linearTickVal); } /** @@ -305,19 +277,13 @@ export function intervalScaleEnsureValidExtent( return extent; } +export function extentDiffers(extent1: number[], extent2: number[]): boolean[] { + return [extent1[0] !== extent2[0], extent1[1] !== extent2[1]]; +} + export function ensureValidSplitNumber( rawSplitNumber: number | NullUndefined, defaultSplitNumber: number ): number { rawSplitNumber = rawSplitNumber || defaultSplitNumber; return mathRound(mathMax(rawSplitNumber, 1)); } - -export function getExtentPrecision( - val: number, - extent: number[], - extentPrecision: (number | NullUndefined)[], -): number | NullUndefined { - return val === extent[0] ? extentPrecision[0] - : val === extent[1] ? extentPrecision[1] - : null; -} diff --git a/src/util/layout.ts b/src/util/layout.ts index 1c138e57cd..41858807bd 100644 --- a/src/util/layout.ts +++ b/src/util/layout.ts @@ -34,7 +34,10 @@ import Element from 'zrender/src/Element'; import { Dictionary } from 'zrender/src/core/types'; import ExtensionAPI from '../core/ExtensionAPI'; import { error } from './log'; -import { BoxCoordinateSystemCoordFrom, getCoordForBoxCoordSys } from '../core/CoordinateSystem'; +import { + BOX_COORD_SYS_COORD_FROM_PROP_COORD2, BoxCoordinateSystemCoordFrom, + getCoordForCoordSysUsageKindBox +} from '../core/CoordinateSystem'; import SeriesModel from '../model/Series'; import type Model from '../model/Model'; import type ComponentModel from '../model/Component'; @@ -208,7 +211,7 @@ function getViewRectAndCenterForCircleLayout() { +export function makeInner() { const key = '__ec_inner_' + innerUniqueIndex++; return function (hostObj: Host): T { return (hostObj as any)[key] || ((hostObj as any)[key] = {}); @@ -1173,3 +1172,28 @@ export function clearTmpModel(model: Model): void { // Clear to avoid memory leak. model.option = model.parentModel = model.ecModel = null; } + +export function initExtentForUnion(): [number, number] { + return [Infinity, -Infinity]; +} + +/** + * A util for ensuring the callback is called only once. + * @usage + * const callOnlyOnce = makeCallOnlyOnce(); // Should be static (ESM top level). + * function someFunc(hostObj) { + * callOnlyOnce(hostObj, function () { + * // Do something immediately and only once for hostObj. + * } + * } + */ +export function makeCallOnlyOnce() { + const key = '__ec_once_' + onceUniqueIndex++; + return function (hostObj: Host, cb: () => void) { + if (!hasOwn(hostObj, key)) { + (hostObj as any)[key] = 1; + cb(); + } + }; +} +let onceUniqueIndex = getRandomIdBase(); diff --git a/src/util/types.ts b/src/util/types.ts index 1d6521d51e..3520eb110f 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -340,6 +340,8 @@ export interface StageHandler { seriesType?: string; /** * Indicate that the task will be only piped in the pipeline of the returned series. + * Called in "prepare" stage, before coord sys creation. + * It is available for both `reset` and `overallReset`. */ getTargetSeries?: (ecModel: GlobalModel, api: ExtensionAPI) => HashMap; @@ -421,12 +423,20 @@ export type OrdinalRawValue = string | number; export type OrdinalNumber = number; // The number mapped from each OrdinalRawValue. /** - * @usage For example, - * ```js - * { ordinalNumbers: [2, 5, 3, 4] } - * ``` - * means that ordinal 2 should be displayed on tick 0, - * ordinal 5 should be displayed on tick 1, ... + * @usage + * For example, + * ```js + * { ordinalNumbers: [2, 5, 3, 4] } + * ``` + * means that "ordinal number" 2 should be displayed on `tick.value` 0, + * "ordinal number" 5 should be displayed on `tick.value` 1, ... + * NOTICE: + * - The index/key of `ordinalNumbers` is "tick.value" rather than the index of + * `scale.getTicks()`, though in most cases they are the same, except that the + * `axis.min` is delibrately set to be not zero. + * - The value of `ordinalNumbers` must be a valid `OrdinalNumber`; + * null/undefined is not supported. + * - `OrdinalNumber` is always from `0` to `ordinalMeta.categories.length - 1`. */ export type OrdinalSortInfo = { ordinalNumbers: OrdinalNumber[]; @@ -1761,8 +1771,10 @@ export interface ComponentOption { } /** - * - "data": Use it as "dataCoordSys", each data item is laid out based on a coord sys. - * - "box": Use it as "boxCoordSys", the overall bounding rect or anchor point is calculated based on a coord sys. + * - "data": Each data item is laid out based on a coord sys. + * See `COORD_SYS_USAGE_KIND_DATA`. + * - "box": The overall bounding rect or anchor point is calculated based on a coord sys. + * See `COORD_SYS_USAGE_KIND_BOX`. * e.g., * grid rect (cartesian rect) is calculate based on matrix/calendar coord sys; * pie center is calculated based on calendar/cartesian; From dedc5dc1857e74cdac8ec7a834242ae2ba286d72 Mon Sep 17 00:00:00 2001 From: 100pah Date: Sun, 25 Jan 2026 22:56:30 +0800 Subject: [PATCH 10/31] fix(logScale): (1) Thoroughly resolve a long-standing issue of non-positive data on LogScale - exclude non-positive series data items when calculate dataExtent on LogScale. (2) Include `Infinite` into `connectNulls` handling on line series; the `Infinite` value may be generated by `log(0)` and previously the corresponding effect in unpredictable on line series (sometimes display as connected but sometimes not). --- src/chart/line/LineSeries.ts | 2 +- src/chart/line/LineView.ts | 50 ++-- src/chart/line/helper.ts | 10 + src/chart/line/poly.ts | 19 +- src/coord/axisAlignTicks.ts | 4 +- src/coord/axisHelper.ts | 15 +- .../cartesian/defaultAxisExtentFromData.ts | 4 +- src/coord/scaleRawExtentInfo.ts | 42 +-- src/data/DataStore.ts | 87 ++++-- src/data/SeriesData.ts | 13 +- src/model/Series.ts | 1 + src/scale/Log.ts | 9 +- src/scale/breakImpl.ts | 6 +- src/scale/helper.ts | 21 +- src/util/model.ts | 9 + test/area-stack.html | 1 + test/logScale.html | 279 ++++++++++-------- test/runTest/actions/__meta__.json | 2 +- test/runTest/actions/logScale.json | 2 +- 19 files changed, 341 insertions(+), 235 deletions(-) diff --git a/src/chart/line/LineSeries.ts b/src/chart/line/LineSeries.ts index 0cd0c9ef83..88dbdb4044 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -209,7 +209,7 @@ class LineSeriesModel extends SeriesModel { // follow the label interval strategy. showAllSymbol: 'auto', - // Whether to connect break point. + // Whether to connect break point. (non-finite values) connectNulls: false, // Sampling for large data. Can be: 'average', 'max', 'min', 'sum', 'lttb'. diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 3682d96512..32c6ee4dff 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -27,7 +27,7 @@ import * as graphic from '../../util/graphic'; import * as modelUtil from '../../util/model'; import { ECPolyline, ECPolygon } from './poly'; import ChartView from '../../view/Chart'; -import { prepareDataCoordInfo, getStackedOnPoint } from './helper'; +import { prepareDataCoordInfo, getStackedOnPoint, isPointIllegal } from './helper'; import { createGridClipPath, createPolarClipPath } from '../helper/createClipPathFromCoordSys'; import LineSeriesModel, { LineSeriesOption } from './LineSeries'; import type GlobalModel from '../../model/Global'; @@ -84,42 +84,33 @@ function isPointsSame(points1: ArrayLike, points2: ArrayLike) { return true; } -function bboxFromPoints(points: ArrayLike) { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; +function xyExtentFromPoints(points: ArrayLike) { + const xExtent = modelUtil.initExtentForUnion(); + const yExtent = modelUtil.initExtentForUnion(); for (let i = 0; i < points.length;) { const x = points[i++]; const y = points[i++]; - if (!isNaN(x)) { - minX = Math.min(x, minX); - maxX = Math.max(x, maxX); - } - if (!isNaN(y)) { - minY = Math.min(y, minY); - maxY = Math.max(y, maxY); + if (!isPointIllegal(x, y)) { + modelUtil.unionExtent(xExtent, x); + modelUtil.unionExtent(yExtent, y); } } - return [ - [minX, minY], - [maxX, maxY] - ]; + return [xExtent, yExtent]; } function getBoundingDiff(points1: ArrayLike, points2: ArrayLike): number { - const [min1, max1] = bboxFromPoints(points1); - const [min2, max2] = bboxFromPoints(points2); + const [xExtent1, yExtent1] = xyExtentFromPoints(points1); + const [xExtent2, yExtent2] = xyExtentFromPoints(points2); // Get a max value from each corner of two boundings. return Math.max( - Math.abs(min1[0] - min2[0]), - Math.abs(min1[1] - min2[1]), + Math.abs(xExtent1[0] - xExtent2[0]), + Math.abs(yExtent1[0] - yExtent2[0]), - Math.abs(max1[0] - max2[0]), - Math.abs(max1[1] - max2[1]) + Math.abs(xExtent1[1] - xExtent2[1]), + Math.abs(yExtent1[1] - yExtent2[1]) ); } @@ -181,7 +172,7 @@ function turnPointsIntoStep( * should stay the same as the lines above. See #20021 */ const reference = basePoints || points; - if (!isNaN(reference[i]) && !isNaN(reference[i + 1])) { + if (!isPointIllegal(reference[i], reference[i + 1])) { filteredPoints.push(points[i], points[i + 1]); } } @@ -447,15 +438,10 @@ function canShowAllSymbolForCategory( return true; } - -function isPointNull(x: number, y: number) { - return isNaN(x) || isNaN(y); -} - function getLastIndexNotNull(points: ArrayLike) { let len = points.length / 2; for (; len > 0; len--) { - if (!isPointNull(points[len * 2 - 2], points[len * 2 - 1])) { + if (!isPointIllegal(points[len * 2 - 2], points[len * 2 - 1])) { break; } } @@ -477,7 +463,7 @@ function getIndexRange(points: ArrayLike, xOrY: number, dim: 'x' | 'y') let nextIndex = -1; for (let i = 0; i < len; i++) { b = points[i * 2 + dimIdx]; - if (isNaN(b) || isNaN(points[i * 2 + 1 - dimIdx])) { + if (isPointIllegal(b, points[i * 2 + 1 - dimIdx])) { continue; } if (i === 0) { @@ -965,7 +951,7 @@ class LineView extends ChartView { // Create a temporary symbol if it is not exists const x = points[dataIndex * 2]; const y = points[dataIndex * 2 + 1]; - if (isNaN(x) || isNaN(y)) { + if (isPointIllegal(x, y)) { // Null data return; } diff --git a/src/chart/line/helper.ts b/src/chart/line/helper.ts index 13a1c2c01a..ba04dba5cd 100644 --- a/src/chart/line/helper.ts +++ b/src/chart/line/helper.ts @@ -132,3 +132,13 @@ export function getStackedOnPoint( return coordSys.dataToPoint(stackedData); } + +export function isPointIllegal(xOrY: number, yOrX: number) { + // NOTE: + // - `NaN` point x/y may be generated by, e.g., + // original series data `NaN`, '-', `null`, `undefined`, + // negative values in LogScale. + // - `Infinite` point x/y may be generated by, e.g., + // original series data `Infinite`, `0` in LogScale. + return !isFinite(xOrY) || !isFinite(yOrX); +} diff --git a/src/chart/line/poly.ts b/src/chart/line/poly.ts index aa6826de8d..10ca2a7472 100644 --- a/src/chart/line/poly.ts +++ b/src/chart/line/poly.ts @@ -23,14 +23,11 @@ import Path, { PathProps } from 'zrender/src/graphic/Path'; import PathProxy from 'zrender/src/core/PathProxy'; import { cubicRootAt, cubicAt } from 'zrender/src/core/curve'; import tokens from '../../visual/tokens'; +import { isPointIllegal } from './helper'; const mathMin = Math.min; const mathMax = Math.max; -function isPointNull(x: number, y: number) { - return isNaN(x) || isNaN(y); -} - /** * Draw smoothed line in non-monotone, in may cause undesired curve in extreme * situations. This should be used when points are non-monotone neither in x or @@ -63,7 +60,7 @@ function drawSegment( if (idx >= allLen || idx < 0) { break; } - if (isPointNull(x, y)) { + if (isPointIllegal(x, y)) { if (connectNulls) { idx += dir; continue; @@ -106,7 +103,7 @@ function drawSegment( let tmpK = k + 1; if (connectNulls) { // Find next point not null - while (isPointNull(nextX, nextY) && tmpK < segLen) { + while (isPointIllegal(nextX, nextY) && tmpK < segLen) { tmpK++; nextIdx += dir; nextX = points[nextIdx * 2]; @@ -120,7 +117,7 @@ function drawSegment( let nextCpx0; let nextCpy0; // Is last point - if (tmpK >= segLen || isPointNull(nextX, nextY)) { + if (tmpK >= segLen || isPointIllegal(nextX, nextY)) { cpx1 = x; cpy1 = y; } @@ -256,12 +253,12 @@ export class ECPolyline extends Path { if (shape.connectNulls) { // Must remove first and last null values avoid draw error in polygon for (; len > 0; len--) { - if (!isPointNull(points[len * 2 - 2], points[len * 2 - 1])) { + if (!isPointIllegal(points[len * 2 - 2], points[len * 2 - 1])) { break; } } for (; i < len; i++) { - if (!isPointNull(points[i * 2], points[i * 2 + 1])) { + if (!isPointIllegal(points[i * 2], points[i * 2 + 1])) { break; } } @@ -380,12 +377,12 @@ export class ECPolygon extends Path { if (shape.connectNulls) { // Must remove first and last null values avoid draw error in polygon for (; len > 0; len--) { - if (!isPointNull(points[len * 2 - 2], points[len * 2 - 1])) { + if (!isPointIllegal(points[len * 2 - 2], points[len * 2 - 1])) { break; } } for (; i < len; i++) { - if (!isPointNull(points[i * 2], points[i * 2 + 1])) { + if (!isPointIllegal(points[i * 2], points[i * 2 + 1])) { break; } } diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 75f2d8d55e..75ba6fedb6 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -159,8 +159,8 @@ export function alignScaleTicks( const targetRawPowExtent = targetRawExtent; if (isTargetLogScale) { targetRawExtent = [ - logScaleLogTick(targetRawExtent[0], targetLogScaleBase, false), - logScaleLogTick(targetRawExtent[1], targetLogScaleBase, false) + logScaleLogTick(targetRawExtent[0], targetLogScaleBase), + logScaleLogTick(targetRawExtent[1], targetLogScaleBase) ]; } const targetExtent = intervalScaleEnsureValidExtent(targetRawExtent, targetMinMaxFixed); diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 45e8790ae2..d21af80844 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -46,8 +46,8 @@ import { import CartesianAxisModel from './cartesian/AxisModel'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; -import { Dictionary, DimensionName, NullUndefined, ScaleTick } from '../util/types'; -import { ensureScaleRawExtentInfo, ScaleRawExtentResult } from './scaleRawExtentInfo'; +import { Dictionary, DimensionName, ScaleTick } from '../util/types'; +import { clampForLogScale, ensureScaleRawExtentInfo, ScaleRawExtentResult } from './scaleRawExtentInfo'; import { parseTimeAxisLabelFormatter } from '../util/time'; import { getScaleBreakHelper } from '../scale/break'; import { error } from '../util/log'; @@ -104,6 +104,11 @@ export function adoptScaleExtentOptionAndPrepare( } } + if (isLogScale(scale)) { + min = clampForLogScale(min); + max = clampForLogScale(max); + } + rawExtentResult.min = min; rawExtentResult.max = max; @@ -306,12 +311,6 @@ export function getDataDimensionsOnAxis(data: SeriesData, axisDim: string): Dime return zrUtil.keys(dataDimMap); } -export function unionExtent(dataExtent: number[], val: number | NullUndefined): void { - // Considered that number could be NaN and should not write into the extent. - val < dataExtent[0] && (dataExtent[0] = val); - val > dataExtent[1] && (dataExtent[1] = val); -} - export function isNameLocationCenter(nameLocation: AxisBaseOptionCommon['nameLocation']) { return nameLocation === 'middle' || nameLocation === 'center'; } diff --git a/src/coord/cartesian/defaultAxisExtentFromData.ts b/src/coord/cartesian/defaultAxisExtentFromData.ts index a9d4950fa8..c2db3b2761 100644 --- a/src/coord/cartesian/defaultAxisExtentFromData.ts +++ b/src/coord/cartesian/defaultAxisExtentFromData.ts @@ -23,7 +23,7 @@ import SeriesModel from '../../model/Series'; import { isCartesian2DDeclaredSeries, findAxisModels, isCartesian2DInjectedAsDataCoordSys } from './cartesianAxisHelper'; -import { getDataDimensionsOnAxis, unionExtent } from '../axisHelper'; +import { getDataDimensionsOnAxis } from '../axisHelper'; import { AxisBaseModel } from '../AxisBaseModel'; import type Axis from '../Axis'; import GlobalModel from '../../model/Global'; @@ -31,7 +31,7 @@ import { Dictionary } from '../../util/types'; import { AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, ensureScaleRawExtentInfo, ScaleRawExtentInfo, ScaleRawExtentResult } from '../scaleRawExtentInfo'; -import { initExtentForUnion } from '../../util/model'; +import { initExtentForUnion, unionExtent } from '../../util/model'; /** * @obsolete diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 419df4915e..8ce80ca311 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -28,8 +28,8 @@ import { DimensionIndex, DimensionName, NullUndefined, ScaleDataValue } from '.. import { isIntervalScale, isLogScale, isOrdinalScale, isTimeScale } from '../scale/helper'; import type Axis from './Axis'; import type SeriesModel from '../model/Series'; -import { makeInner, initExtentForUnion } from '../util/model'; -import { getDataDimensionsOnAxis, unionExtent } from './axisHelper'; +import { makeInner, initExtentForUnion, unionExtent } from '../util/model'; +import { getDataDimensionsOnAxis } from './axisHelper'; import { getCoordForCoordSysUsageKindBox } from '../core/CoordinateSystem'; @@ -83,7 +83,7 @@ export interface ScaleRawExtentResult { export class ScaleRawExtentInfo { private _needCrossZero: ValueAxisBaseOption['scale']; - private _isOrdinal: boolean; + private _scale: Scale; private _axisDataLen: number; private _boundaryGapInner: number[]; @@ -118,26 +118,14 @@ export class ScaleRawExtentInfo { // Typically: data extent from all series on this axis. dataExtent: number[] ) { - this._prepareParams(scale, model, dataExtent); - } + this._scale = scale; - /** - * Parameters depending on outside (like model, user callback) - * are prepared and fixed here. - */ - private _prepareParams( - scale: Scale, - model: AxisBaseModel, - // Usually: data extent from all series on this axis. - dataExtent: number[] - ) { if (dataExtent[1] < dataExtent[0]) { dataExtent = [NaN, NaN]; } this._dataMin = dataExtent[0]; this._dataMax = dataExtent[1]; - const isOrdinal = this._isOrdinal = isOrdinalScale(scale); this._needCrossZero = isIntervalScale(scale) && model.getNeedCrossZero && model.getNeedCrossZero(); if (isIntervalScale(scale) || isLogScale(scale) || isTimeScale(scale)) { @@ -181,7 +169,7 @@ export class ScaleRawExtentInfo { this._modelMaxNum = parseAxisModelMinMax(scale, modelMaxRaw); } - if (isOrdinal) { + if (isOrdinalScale(scale)) { // FIXME: there is a flaw here: if there is no "block" data processor like `dataZoom`, // and progressive rendering is using, here the category result might just only contain // the processed chunk rather than the entire result. @@ -227,7 +215,8 @@ export class ScaleRawExtentInfo { // be the result that originalExtent enlarged by boundaryGap. // (3) If no data, it should be ensured that `scale.setBlank` is set. - const isOrdinal = this._isOrdinal; + const scale = this._scale; + const isOrdinal = isOrdinalScale(scale); let dataMin = this._dataMin; let dataMax = this._dataMax; @@ -313,6 +302,11 @@ export class ScaleRawExtentInfo { maxFixed = maxDetermined = true; } + if (isLogScale(scale)) { + min = clampForLogScale(min); + max = clampForLogScale(max); + } + // Ensure min/max be finite number or NaN here. (not to be null/undefined) // `NaN` means min/max axis is blank. return { @@ -338,6 +332,9 @@ export class ScaleRawExtentInfo { if (__DEV__) { assert(this[attr] == null); } + if (isLogScale(this._scale)) { + val = clampForLogScale(val); + } this[attr] = val; } } @@ -457,8 +454,9 @@ export function axisExtentInfoFinalBuild( // NOTE: This data may have been filtered by dataZoom on orthogonal axes. const data = seriesModel.getData(); if (data) { + const filter = isLogScale(scale) ? {g: 0} : null; each(getDataDimensionsOnAxis(data, axis.dim), function (dim) { - const seriesExtent = data.getApproximateExtent(dim); + const seriesExtent = data.getApproximateExtent(dim, filter); unionExtent(extent, seriesExtent[0]); unionExtent(extent, seriesExtent[1]); }); @@ -503,3 +501,9 @@ function injectScaleRawExtentInfo( // @ts-ignore scaleRawExtentInfo.from = from; } + +export function clampForLogScale(val: number) { + // Avoid `NaN` for log scale. + // See also `DataStore#getDataExtent`. + return val < 0 ? 0 : val; +} diff --git a/src/data/DataStore.ts b/src/data/DataStore.ts index 4a3a0031e3..f0280d06a8 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -21,6 +21,7 @@ import { assert, clone, createHashMap, isFunction, keys, map, reduce } from 'zre import { DimensionIndex, DimensionName, + NullUndefined, OptionDataItem, ParsedValue, ParsedValueNumeric @@ -72,6 +73,9 @@ type FilterCb = (...args: any) => boolean; // type MapArrayCb = (...args: any) => any; type MapCb = (...args: any) => ParsedValue | ParsedValue[]; +// g: greater than, ge: greater equal, l: less than, le: less equal +export type DataStoreExtentFilter = {g?: number; ge?: number; l?: number; le?: number;}; + export type DimValueGetter = ( this: DataStore, dataItem: any, @@ -163,7 +167,9 @@ class DataStore { // It will not be calculated until needed. private _rawExtent: [number, number][] = []; - private _extent: [number, number][] = []; + // structure: + // `const extentOnFilterOnDimension = this._extent[dim][extentFilterKey]` + private _extent: Record[] = []; // Indices stores the indices of data subset after filtered. // This data subset will be used in chart. @@ -829,7 +835,7 @@ class DataStore { let retValue = cb && cb.apply(null, values); if (retValue != null) { - // a number or string (in oridinal dimension)? + // a number or string (in ordinal dimension)? if (typeof retValue !== 'object') { tmpRetValue[0] = retValue; retValue = tmpRetValue; @@ -1126,10 +1132,10 @@ class DataStore { } } - /** - * Get extent of data in one dimension - */ - getDataExtent(dim: DimensionIndex): [number, number] { + getDataExtent( + dim: DimensionIndex, + filter: DataStoreExtentFilter | NullUndefined + ): [number, number] { // Make sure use concrete dim as cache name. const dimData = this._chunks[dim]; const initialExtent = initExtentForUnion(); @@ -1144,33 +1150,74 @@ class DataStore { // Consider the most cases when using data zoom, `getDataExtent` // happened before filtering. We cache raw extent, which is not // necessary to be cleared and recalculated when restore data. - const useRaw = !this._indices; - let dimExtent: [number, number]; - + const useRaw = !this._indices && !filter; if (useRaw) { return this._rawExtent[dim].slice() as [number, number]; } - dimExtent = this._extent[dim]; + + // NOTE: + // - In logarithm axis, zero should be excluded, therefore the `extent[0]` should be less or equal + // than the min positive data item, which requires the special handling here. + // - "Filter non-positive values for logarithm axis" can also be implemented in a data processor + // but that requires more complicated code to not break all streams under the current architecture, + // therefore we simply implement it here. + // - Performance is sensitive for large data, therefore inline filters rather than cb is used here. + + const thisExtent = this._extent; + const dimExtentRecord = thisExtent[dim] || (thisExtent[dim] = {}); + let filterKey = ''; + let filterG = -Infinity; + let filterGE = -Infinity; + let filterL = Infinity; + let filterLE = Infinity; + if (filter) { + if (filter.g != null) { + filterKey += 'G' + filter.g; + filterG = filter.g; + } + if (filter.ge != null) { + filterKey += 'GE' + filter.ge; + filterGE = filter.ge; + } + if (filter.l != null) { + filterKey += 'L' + filter.l; + filterL = filter.l; + } + if (filter.le != null) { + filterKey += 'LE' + filter.le; + filterLE = filter.le; + } + } + const dimExtent = dimExtentRecord[filterKey]; if (dimExtent) { return dimExtent.slice() as [number, number]; } - dimExtent = initialExtent; - let min = dimExtent[0]; - let max = dimExtent[1]; + let min = initialExtent[0]; + let max = initialExtent[1]; + // NOTICE: Performance sensitive on large data. for (let i = 0; i < currEnd; i++) { const rawIdx = this.getRawIndex(i); const value = dimData[rawIdx] as ParsedValueNumeric; - value < min && (min = value); - value > max && (max = value); + if (filter) { + if (value <= filterG + || value < filterGE + || value >= filterL + || value > filterLE + ) { + continue; + } + } + if (value < min) { + min = value; + } + if (value > max) { + max = value; + } } - dimExtent = [min, max]; - - this._extent[dim] = dimExtent; - - return dimExtent; + return (dimExtentRecord[filterKey] = [min, max]); } /** diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index f18aec6b73..c498abd6d7 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -27,7 +27,7 @@ import DataDiffer from './DataDiffer'; import {DataProvider, DefaultDataProvider} from './helper/dataProvider'; import {summarizeDimensions, DimensionSummary} from './helper/dimensionHelper'; import SeriesDimensionDefine from './SeriesDimensionDefine'; -import {ArrayLike, Dictionary, FunctionPropertyNames} from 'zrender/src/core/types'; +import {ArrayLike, Dictionary, FunctionPropertyNames, NullUndefined} from 'zrender/src/core/types'; import Element from 'zrender/src/Element'; import { DimensionIndex, DimensionName, DimensionLoose, OptionDataItem, @@ -44,7 +44,7 @@ import type Tree from './Tree'; import type { VisualMeta } from '../component/visualMap/VisualMapModel'; import {isSourceInstance, Source} from './Source'; import { LineStyleProps } from '../model/mixin/lineStyle'; -import DataStore, { DataStoreDimensionDefine, DimValueGetter } from './DataStore'; +import DataStore, { DataStoreDimensionDefine, DataStoreExtentFilter, DimValueGetter } from './DataStore'; import { isSeriesDataSchema, SeriesDataSchema } from './helper/SeriesDataSchema'; const isObject = zrUtil.isObject; @@ -681,8 +681,11 @@ class SeriesData< * extent calculation will cost more than 10ms and the cache will * be erased because of the filtering. */ - getApproximateExtent(dim: SeriesDimensionLoose): [number, number] { - return this._approximateExtent[dim] || this._store.getDataExtent(this._getStoreDimIndex(dim)); + getApproximateExtent( + dim: SeriesDimensionLoose, + filter: DataStoreExtentFilter | NullUndefined + ): [number, number] { + return this._approximateExtent[dim] || this._store.getDataExtent(this._getStoreDimIndex(dim), filter); } /** @@ -789,7 +792,7 @@ class SeriesData< } getDataExtent(dim: DimensionLoose): [number, number] { - return this._store.getDataExtent(this._getStoreDimIndex(dim)); + return this._store.getDataExtent(this._getStoreDimIndex(dim), null); } getSum(dim: DimensionLoose): number { diff --git a/src/model/Series.ts b/src/model/Series.ts index 17779c912a..f4fe5fb8da 100644 --- a/src/model/Series.ts +++ b/src/model/Series.ts @@ -537,6 +537,7 @@ class SeriesModel extends ComponentMode } restoreData() { + // See `dataTaskReset`. this.dataTask.dirty(); } diff --git a/src/scale/Log.ts b/src/scale/Log.ts index 0ce0b55d99..aae2f8e577 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -119,11 +119,14 @@ class LogScale extends Scale { return this.linearStub.getLabel(data, opt); } + /** + * NOTICE: The caller should ensure `start` and `end` are both non-negative. + */ setExtent(start: number, end: number): void { this.powStub.setExtent(start, end); this.linearStub.setExtent( - logScaleLogTick(start, this.base, false), - logScaleLogTick(end, this.base, false) + logScaleLogTick(start, this.base), + logScaleLogTick(end, this.base) ); } @@ -140,7 +143,7 @@ class LogScale extends Scale { } normalize(val: number): number { - return this.linearStub.normalize(logScaleLogTick(val, this.base, true)); + return this.linearStub.normalize(logScaleLogTick(val, this.base)); } scale(val: number): number { diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index 9f88690350..57da02a877 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -675,12 +675,12 @@ function logarithmicParseBreaksFromOption( const parsedLogged = parseAxisBreakOption(breakOptionList, parse, opt); parsedLogged.breaks = map(parsedLogged.breaks, brk => { - const vmin = logScaleLogTick(brk.vmin, logBase, true); - const vmax = logScaleLogTick(brk.vmax, logBase, true); + const vmin = logScaleLogTick(brk.vmin, logBase); + const vmax = logScaleLogTick(brk.vmax, logBase); const gapParsed = { type: brk.gapParsed.type, val: brk.gapParsed.type === 'tpAbs' - ? logScaleLogTick(brk.vmin + brk.gapParsed.val, logBase, true) - vmin + ? logScaleLogTick(brk.vmin + brk.gapParsed.val, logBase) - vmin : brk.gapParsed.val, }; return { diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 4cddbc1aa3..1d17d7d981 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -183,20 +183,23 @@ function scale( } /** - * NOTE: if `val` is `NaN`, return `NaN`. + * NOTE: + * - If `val` is `NaN`, return `NaN`. + * - If `val` is `0`, return `-Infinity`. + * - If `val` is negative, return `NaN`. + * + * @see {DataStore#getDataExtent} It handles non-positive values for logarithm scale. */ export function logScaleLogTick( val: number, base: number, - noClampNegative: boolean ): number { - // log(negative) is NaN, so safe guard here. - // PENDING: But even getting a -Infinity still does not make sense in extent. - // Just keep it as is, getting a NaN to make some previous cases works by coincidence. - return mathLog(noClampNegative ? val : mathMax(0, val)) / mathLog(base); - // NOTE: rounding error may happen above, typically expecting `log10(1000)` but actually - // getting `2.9999999999999996`, but generally it does not matter since they are not - // used to display. + // NOTE: + // - rounding error may happen above, typically expecting `log10(1000)` but actually + // getting `2.9999999999999996`, but generally it does not matter since they are not + // used to display. + // - Consider backward compatibility and other log bases, do not use `Math.log10`. + return mathLog(val) / mathLog(base); } /** diff --git a/src/util/model.ts b/src/util/model.ts index 4330f6d365..34ea2e39eb 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -1177,6 +1177,15 @@ export function initExtentForUnion(): [number, number] { return [Infinity, -Infinity]; } +/** + * Suppose `extent` is initialized as `initExtentForUnion()`. + */ +export function unionExtent(extent: number[], val: number | NullUndefined): void { + // Considered that number could be NaN and should not write into the extent. + val < extent[0] && (extent[0] = val); + val > extent[1] && (extent[1] = val); +} + /** * A util for ensuring the callback is called only once. * @usage diff --git a/test/area-stack.html b/test/area-stack.html index c60eaa1b64..ceb63aacec 100644 --- a/test/area-stack.html +++ b/test/area-stack.html @@ -126,6 +126,7 @@ var option = { legend: { + top: 5, }, toolbox: { feature: { diff --git a/test/logScale.html b/test/logScale.html index 028256139a..113337e8d5 100644 --- a/test/logScale.html +++ b/test/logScale.html @@ -39,6 +39,7 @@
+
+ + + + \ No newline at end of file diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index a4e134f69b..b61d378e59 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -145,7 +145,7 @@ "line-visual2": 2, "lines-bus": 1, "lines-symbolSize-update": 1, - "logScale": 3, + "logScale": 4, "map": 3, "map-contour": 2, "map-default": 1, diff --git a/test/runTest/actions/logScale.json b/test/runTest/actions/logScale.json index 5115c2910a..fd0c978cfe 100644 --- a/test/runTest/actions/logScale.json +++ b/test/runTest/actions/logScale.json @@ -1 +1 @@ -[{"name":"Action 1","ops":[{"type":"mousemove","time":232,"x":764,"y":253},{"type":"mousemove","time":438,"x":142,"y":325},{"type":"mousemove","time":648,"x":79,"y":378},{"type":"mousemove","time":854,"x":58,"y":408},{"type":"mousemove","time":1073,"x":42,"y":435},{"type":"mousedown","time":1091,"x":42,"y":435},{"type":"mouseup","time":1207,"x":42,"y":435},{"time":1208,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1781,"x":43,"y":435},{"type":"mousemove","time":1981,"x":125,"y":423},{"type":"mousedown","time":2027,"x":126,"y":423},{"type":"mouseup","time":2173,"x":126,"y":423},{"time":2174,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2190,"x":126,"y":423},{"type":"mousemove","time":2397,"x":132,"y":423},{"type":"mousedown","time":2478,"x":134,"y":424},{"type":"mouseup","time":2558,"x":134,"y":424},{"time":2559,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2603,"x":134,"y":424},{"type":"mousemove","time":2814,"x":197,"y":424},{"type":"mousedown","time":2976,"x":195,"y":426},{"type":"mousemove","time":3030,"x":195,"y":426},{"type":"mouseup","time":3056,"x":195,"y":426},{"time":3057,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3298,"x":193,"y":426},{"type":"mousemove","time":3498,"x":148,"y":431},{"type":"mousedown","time":3612,"x":129,"y":430},{"type":"mousemove","time":3706,"x":129,"y":430},{"type":"mouseup","time":3739,"x":129,"y":430},{"time":3740,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4182,"x":131,"y":430},{"type":"mousemove","time":4388,"x":160,"y":433},{"type":"mousemove","time":4597,"x":132,"y":429},{"type":"mousemove","time":4797,"x":71,"y":427},{"type":"mousedown","time":4897,"x":68,"y":428},{"type":"mousemove","time":5004,"x":68,"y":428},{"type":"mouseup","time":5042,"x":68,"y":428},{"time":5043,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5114,"x":70,"y":428},{"type":"mousemove","time":5315,"x":117,"y":428},{"type":"mousedown","time":5413,"x":120,"y":428},{"type":"mouseup","time":5507,"x":120,"y":428},{"time":5508,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5544,"x":120,"y":428},{"type":"mousemove","time":5748,"x":238,"y":428},{"type":"mousemove","time":5948,"x":224,"y":431},{"type":"mousedown","time":6158,"x":217,"y":432},{"type":"mousemove","time":6166,"x":217,"y":432},{"type":"mouseup","time":6273,"x":217,"y":432},{"time":6274,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6515,"x":217,"y":432},{"type":"mousemove","time":6724,"x":469,"y":357},{"type":"mousemove","time":6926,"x":482,"y":345}],"scrollY":0,"scrollX":0,"timestamp":1753036575835},{"name":"Action 2","ops":[{"type":"mousemove","time":666,"x":355,"y":199},{"type":"mousedown","time":840,"x":350,"y":195},{"type":"mousemove","time":872,"x":350,"y":195},{"type":"mouseup","time":989,"x":350,"y":195},{"time":990,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1465,"x":351,"y":195},{"type":"mousemove","time":1665,"x":440,"y":190},{"type":"mousemove","time":1865,"x":440,"y":191},{"type":"mousedown","time":1893,"x":440,"y":191},{"type":"mouseup","time":2056,"x":440,"y":191},{"time":2057,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2075,"x":440,"y":191},{"type":"mousemove","time":2132,"x":440,"y":191},{"type":"mousemove","time":2332,"x":378,"y":195},{"type":"mousedown","time":2512,"x":353,"y":196},{"type":"mousemove","time":2540,"x":353,"y":196},{"type":"mouseup","time":2640,"x":353,"y":196},{"time":2641,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2699,"x":353,"y":196},{"type":"mousemove","time":2899,"x":434,"y":196},{"type":"mousedown","time":3044,"x":441,"y":196},{"type":"mousemove","time":3107,"x":441,"y":196},{"type":"mouseup","time":3173,"x":441,"y":196},{"time":3174,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6560,"x":708,"y":342}],"scrollY":793,"scrollX":0,"timestamp":1753036589951},{"name":"Action 3","ops":[{"type":"mousemove","time":299,"x":236,"y":188},{"type":"mousemove","time":499,"x":118,"y":179},{"type":"mousemove","time":699,"x":96,"y":180},{"type":"mousemove","time":918,"x":90,"y":177},{"type":"valuechange","selector":"#main_small_values>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":2201,"target":"select"},{"time":2202,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2317,"x":93,"y":202},{"type":"mousemove","time":2516,"x":93,"y":185},{"type":"mousemove","time":2723,"x":93,"y":179},{"type":"valuechange","selector":"#main_small_values>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":3596,"target":"select"},{"time":3597,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3799,"x":91,"y":177},{"type":"valuechange","selector":"#main_small_values>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":4864,"target":"select"},{"time":4865,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4990,"x":105,"y":221},{"type":"mousemove","time":5190,"x":94,"y":202},{"type":"mousemove","time":5367,"x":131,"y":252},{"type":"mousemove","time":5575,"x":168,"y":280},{"type":"mousemove","time":5785,"x":154,"y":282},{"type":"mousemove","time":5985,"x":174,"y":297},{"type":"mousemove","time":6186,"x":237,"y":340},{"type":"mousemove","time":6397,"x":269,"y":365},{"type":"mousemove","time":6609,"x":276,"y":366},{"type":"mousemove","time":6750,"x":276,"y":366},{"type":"mousemove","time":7238,"x":274,"y":366},{"type":"mousemove","time":7443,"x":268,"y":369},{"type":"mousemove","time":7850,"x":270,"y":369},{"type":"mousemove","time":8049,"x":350,"y":376},{"type":"mousemove","time":8250,"x":357,"y":379},{"type":"mousemove","time":8460,"x":331,"y":374},{"type":"mousemove","time":8900,"x":332,"y":374},{"type":"mousemove","time":9100,"x":720,"y":469},{"type":"mousemove","time":9307,"x":679,"y":471},{"type":"mousemove","time":9516,"x":713,"y":491},{"type":"mousemove","time":9716,"x":714,"y":491},{"type":"mousemove","time":9926,"x":721,"y":491},{"type":"mousemove","time":10549,"x":721,"y":491},{"type":"mousemove","time":10750,"x":607,"y":381},{"type":"mousedown","time":10860,"x":572,"y":351},{"type":"mouseup","time":10947,"x":572,"y":351},{"time":10948,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10965,"x":572,"y":351}],"scrollY":1273,"scrollX":0,"timestamp":1753036600467}] \ No newline at end of file +[{"name":"Action 1","ops":[{"type":"mousemove","time":232,"x":764,"y":253},{"type":"mousemove","time":438,"x":142,"y":325},{"type":"mousemove","time":648,"x":79,"y":378},{"type":"mousemove","time":854,"x":58,"y":408},{"type":"mousemove","time":1073,"x":42,"y":435},{"type":"mousedown","time":1091,"x":42,"y":435},{"type":"mouseup","time":1207,"x":42,"y":435},{"time":1208,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1781,"x":43,"y":435},{"type":"mousemove","time":1981,"x":125,"y":423},{"type":"mousedown","time":2027,"x":126,"y":423},{"type":"mouseup","time":2173,"x":126,"y":423},{"time":2174,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2190,"x":126,"y":423},{"type":"mousemove","time":2397,"x":132,"y":423},{"type":"mousedown","time":2478,"x":134,"y":424},{"type":"mouseup","time":2558,"x":134,"y":424},{"time":2559,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2603,"x":134,"y":424},{"type":"mousemove","time":2814,"x":197,"y":424},{"type":"mousedown","time":2976,"x":195,"y":426},{"type":"mousemove","time":3030,"x":195,"y":426},{"type":"mouseup","time":3056,"x":195,"y":426},{"time":3057,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3298,"x":193,"y":426},{"type":"mousemove","time":3498,"x":148,"y":431},{"type":"mousedown","time":3612,"x":129,"y":430},{"type":"mousemove","time":3706,"x":129,"y":430},{"type":"mouseup","time":3739,"x":129,"y":430},{"time":3740,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4182,"x":131,"y":430},{"type":"mousemove","time":4388,"x":160,"y":433},{"type":"mousemove","time":4597,"x":132,"y":429},{"type":"mousemove","time":4797,"x":71,"y":427},{"type":"mousedown","time":4897,"x":68,"y":428},{"type":"mousemove","time":5004,"x":68,"y":428},{"type":"mouseup","time":5042,"x":68,"y":428},{"time":5043,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5114,"x":70,"y":428},{"type":"mousemove","time":5315,"x":117,"y":428},{"type":"mousedown","time":5413,"x":120,"y":428},{"type":"mouseup","time":5507,"x":120,"y":428},{"time":5508,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5544,"x":120,"y":428},{"type":"mousemove","time":5748,"x":238,"y":428},{"type":"mousemove","time":5948,"x":224,"y":431},{"type":"mousedown","time":6158,"x":217,"y":432},{"type":"mousemove","time":6166,"x":217,"y":432},{"type":"mouseup","time":6273,"x":217,"y":432},{"time":6274,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6515,"x":217,"y":432},{"type":"mousemove","time":6724,"x":469,"y":357},{"type":"mousemove","time":6926,"x":482,"y":345}],"scrollY":0,"scrollX":0,"timestamp":1753036575835},{"name":"Action 2","ops":[{"type":"mousemove","time":796,"x":736,"y":162},{"type":"mousemove","time":1002,"x":657,"y":201},{"type":"mousemove","time":1219,"x":474,"y":285},{"type":"mousemove","time":1412,"x":473,"y":285},{"type":"mousemove","time":1612,"x":242,"y":361},{"type":"mousemove","time":1819,"x":223,"y":377},{"type":"mousemove","time":2028,"x":218,"y":371},{"type":"mousemove","time":2237,"x":217,"y":370},{"type":"mousemove","time":2345,"x":218,"y":370},{"type":"mousemove","time":2545,"x":342,"y":396},{"type":"mousemove","time":2746,"x":403,"y":405},{"type":"mousemove","time":2955,"x":407,"y":399},{"type":"mousemove","time":3175,"x":398,"y":393},{"type":"mousemove","time":3379,"x":404,"y":430},{"type":"mousemove","time":3579,"x":406,"y":436},{"type":"mousemove","time":3788,"x":464,"y":403},{"type":"mousemove","time":3995,"x":489,"y":381},{"type":"mousemove","time":4196,"x":494,"y":386},{"type":"mousemove","time":4404,"x":493,"y":403},{"type":"mousemove","time":4612,"x":492,"y":419},{"type":"mousemove","time":4821,"x":492,"y":423},{"type":"mousemove","time":5012,"x":496,"y":421},{"type":"mousemove","time":5212,"x":667,"y":302},{"type":"mousemove","time":5412,"x":702,"y":268},{"type":"mousemove","time":5612,"x":720,"y":248},{"type":"mousemove","time":5813,"x":717,"y":251},{"type":"mousemove","time":6024,"x":715,"y":252},{"type":"mousemove","time":6229,"x":540,"y":331},{"type":"mousemove","time":6428,"x":293,"y":496},{"type":"mousemove","time":6628,"x":280,"y":501},{"type":"mousedown","time":6791,"x":277,"y":504},{"type":"mousemove","time":6839,"x":277,"y":504},{"type":"mouseup","time":6958,"x":277,"y":504},{"time":6959,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7021,"x":277,"y":504},{"type":"mousemove","time":7224,"x":343,"y":504},{"type":"mousedown","time":7410,"x":365,"y":504},{"type":"mousemove","time":7444,"x":365,"y":504},{"type":"mouseup","time":7506,"x":365,"y":504},{"time":7507,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7662,"x":364,"y":504},{"type":"mousemove","time":7863,"x":345,"y":511},{"type":"mousemove","time":8063,"x":299,"y":531},{"type":"mousemove","time":8272,"x":298,"y":532},{"type":"mousemove","time":8495,"x":295,"y":556},{"type":"mousemove","time":8695,"x":309,"y":538},{"type":"mousedown","time":8873,"x":312,"y":531},{"type":"mousemove","time":8905,"x":312,"y":531},{"type":"mouseup","time":9022,"x":312,"y":531},{"time":9023,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9079,"x":312,"y":532},{"type":"mousemove","time":9289,"x":311,"y":549},{"type":"mousedown","time":9341,"x":311,"y":550},{"type":"mouseup","time":9489,"x":311,"y":550},{"time":9490,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9524,"x":311,"y":550},{"type":"mousemove","time":9535,"x":311,"y":550},{"type":"mousemove","time":9742,"x":310,"y":524},{"type":"mousedown","time":9956,"x":307,"y":508},{"type":"mousemove","time":9974,"x":307,"y":508},{"type":"mouseup","time":10104,"x":307,"y":508},{"time":10105,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10228,"x":308,"y":508},{"type":"mousemove","time":10429,"x":366,"y":508},{"type":"mousedown","time":10541,"x":370,"y":507},{"type":"mousemove","time":10638,"x":370,"y":507},{"type":"mouseup","time":10756,"x":370,"y":507},{"time":10757,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11045,"x":370,"y":507},{"type":"mousemove","time":11245,"x":327,"y":529},{"type":"mousemove","time":11445,"x":321,"y":532},{"type":"mousedown","time":11464,"x":321,"y":532},{"type":"mouseup","time":11605,"x":321,"y":532},{"time":11606,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11696,"x":321,"y":536},{"type":"mousemove","time":11907,"x":321,"y":553},{"type":"mousedown","time":11989,"x":321,"y":554},{"type":"mouseup","time":12123,"x":321,"y":554},{"time":12124,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12177,"x":321,"y":554},{"type":"mousemove","time":12446,"x":321,"y":553},{"type":"mousemove","time":12657,"x":195,"y":262},{"type":"mousemove","time":12880,"x":195,"y":261},{"type":"mousemove","time":13092,"x":164,"y":215},{"type":"mousemove","time":13296,"x":137,"y":166},{"type":"mousemove","time":13505,"x":136,"y":162},{"type":"mousemove","time":13721,"x":134,"y":155},{"type":"mousemove","time":13895,"x":135,"y":156},{"type":"mousemove","time":14096,"x":136,"y":157},{"type":"mousemove","time":14312,"x":253,"y":151},{"type":"mousemove","time":14512,"x":315,"y":147},{"type":"mousemove","time":14713,"x":316,"y":162},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"2","time":16359,"target":"select"},{"time":16360,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16396,"x":318,"y":185},{"type":"mousemove","time":16596,"x":330,"y":162},{"type":"mousemove","time":16796,"x":333,"y":158},{"type":"mousemove","time":17004,"x":334,"y":158},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"1","time":18172,"target":"select"},{"time":18173,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":18213,"x":334,"y":163},{"type":"mousemove","time":18424,"x":335,"y":157},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"0","time":19637,"target":"select"},{"time":19638,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":19680,"x":218,"y":160},{"type":"mousemove","time":19888,"x":198,"y":161},{"type":"mousemove","time":19928,"x":194,"y":161},{"type":"mousemove","time":20129,"x":140,"y":161},{"type":"mousemove","time":20339,"x":135,"y":164},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":22388,"target":"select"},{"time":22389,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":22396,"x":109,"y":228},{"type":"mousemove","time":22846,"x":109,"y":227},{"type":"mousemove","time":23055,"x":111,"y":214},{"type":"mousemove","time":23262,"x":114,"y":187},{"type":"mousemove","time":23462,"x":117,"y":161},{"type":"mousemove","time":23670,"x":117,"y":161},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":24795,"target":"select"},{"time":24796,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":24815,"x":121,"y":135},{"type":"mousemove","time":25021,"x":125,"y":135},{"type":"mousemove","time":25229,"x":125,"y":139},{"type":"mousemove","time":25429,"x":120,"y":149},{"type":"mousemove","time":25638,"x":115,"y":157},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":26569,"target":"select"},{"time":26570,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":26597,"x":115,"y":149},{"type":"mousemove","time":26805,"x":112,"y":159},{"type":"mousemove","time":27023,"x":112,"y":162},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":28353,"target":"select"},{"time":28354,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":28396,"x":115,"y":228},{"type":"mousemove","time":28606,"x":407,"y":211},{"type":"mousemove","time":28812,"x":423,"y":190},{"type":"mousemove","time":29012,"x":431,"y":178},{"type":"mousemove","time":29212,"x":210,"y":157},{"type":"mousemove","time":29413,"x":153,"y":155},{"type":"mousemove","time":29621,"x":142,"y":158},{"type":"valuechange","selector":"#main2_negative>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":30721,"target":"select"},{"time":30722,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":30749,"x":183,"y":131},{"type":"mousemove","time":30956,"x":430,"y":200},{"type":"mousemove","time":31163,"x":471,"y":184},{"type":"mousedown","time":31337,"x":474,"y":178},{"type":"mousemove","time":31371,"x":474,"y":178},{"type":"mouseup","time":31438,"x":474,"y":178},{"time":31439,"delay":400,"type":"screenshot-auto"}],"scrollY":909,"scrollX":0,"timestamp":1769352377131},{"name":"Action 3","ops":[{"type":"mousemove","time":563,"x":452,"y":165},{"type":"mousemove","time":763,"x":439,"y":166},{"type":"mousemove","time":963,"x":236,"y":242},{"type":"mousemove","time":1163,"x":189,"y":280},{"type":"mousemove","time":1363,"x":239,"y":322},{"type":"mousemove","time":1563,"x":261,"y":320},{"type":"mousemove","time":1763,"x":268,"y":323},{"type":"mousemove","time":1963,"x":273,"y":325},{"type":"mousemove","time":2171,"x":272,"y":325},{"type":"mousemove","time":2246,"x":271,"y":325},{"type":"mousemove","time":2446,"x":214,"y":270},{"type":"mousemove","time":2646,"x":185,"y":255},{"type":"mousemove","time":2847,"x":161,"y":245},{"type":"mousemove","time":3047,"x":159,"y":242},{"type":"mousemove","time":3254,"x":159,"y":241},{"type":"mousemove","time":3463,"x":631,"y":416},{"type":"mousemove","time":3671,"x":643,"y":419},{"type":"mousemove","time":3889,"x":728,"y":462},{"type":"mousemove","time":4105,"x":715,"y":449},{"type":"mousemove","time":4322,"x":718,"y":451},{"type":"mousemove","time":4430,"x":717,"y":451},{"type":"mousemove","time":4639,"x":210,"y":113},{"type":"mousemove","time":4847,"x":162,"y":123},{"type":"mousemove","time":5055,"x":103,"y":133},{"type":"mousemove","time":5273,"x":95,"y":139},{"type":"valuechange","selector":"#main_small_values>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":6123,"target":"select"},{"time":6124,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6163,"x":85,"y":144},{"type":"mousemove","time":6596,"x":85,"y":143},{"type":"valuechange","selector":"#main_small_values>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":7747,"target":"select"},{"time":7748,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7797,"x":85,"y":148},{"type":"mousemove","time":7997,"x":85,"y":145},{"type":"mousemove","time":8207,"x":85,"y":143},{"type":"valuechange","selector":"#main_small_values>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-normal>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":9030,"target":"select"},{"time":9031,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9052,"x":125,"y":167},{"type":"mousemove","time":9257,"x":248,"y":167},{"type":"mousemove","time":9464,"x":270,"y":173},{"type":"mousemove","time":9673,"x":250,"y":211},{"type":"mousemove","time":9889,"x":249,"y":214},{"type":"mousedown","time":10023,"x":249,"y":214},{"type":"mouseup","time":10155,"x":249,"y":214},{"time":10156,"delay":400,"type":"screenshot-auto"}],"scrollY":1426,"scrollX":0,"timestamp":1769352431881},{"name":"Action 4","ops":[{"type":"mousemove","time":740,"x":519,"y":243},{"type":"mousemove","time":940,"x":431,"y":321},{"type":"mousemove","time":1140,"x":347,"y":410},{"type":"mousemove","time":1369,"x":347,"y":421},{"type":"mousemove","time":1570,"x":316,"y":422},{"type":"mousemove","time":1776,"x":279,"y":426},{"type":"mousemove","time":1979,"x":264,"y":427},{"type":"mousemove","time":2183,"x":244,"y":432},{"type":"mousemove","time":2399,"x":244,"y":432},{"type":"mousemove","time":2540,"x":240,"y":432},{"type":"mousemove","time":2752,"x":151,"y":492},{"type":"mousemove","time":2957,"x":147,"y":522},{"type":"mousemove","time":3165,"x":157,"y":523},{"type":"mousemove","time":3373,"x":145,"y":501},{"type":"mousemove","time":3582,"x":111,"y":518},{"type":"mousemove","time":3783,"x":264,"y":431},{"type":"mousewheel","time":3956,"x":264,"y":431,"deltaY":1},{"type":"mousewheel","time":3988,"x":264,"y":431,"deltaY":3},{"type":"mousewheel","time":4008,"x":264,"y":431,"deltaY":7},{"type":"mousewheel","time":4028,"x":264,"y":431,"deltaY":4},{"type":"mousewheel","time":4049,"x":264,"y":431,"deltaY":3},{"type":"mousewheel","time":4070,"x":264,"y":431,"deltaY":2},{"type":"mousewheel","time":4095,"x":264,"y":431,"deltaY":4},{"type":"mousewheel","time":4115,"x":264,"y":431,"deltaY":1},{"type":"mousewheel","time":4136,"x":264,"y":431,"deltaY":1},{"type":"mousewheel","time":4156,"x":264,"y":431,"deltaY":2},{"type":"mousewheel","time":5207,"x":264,"y":431,"deltaY":-1},{"type":"mousewheel","time":5231,"x":264,"y":431,"deltaY":-1},{"type":"mousewheel","time":5439,"x":264,"y":431,"deltaY":-1},{"type":"mousewheel","time":5467,"x":264,"y":431,"deltaY":-1},{"type":"mousewheel","time":5491,"x":264,"y":431,"deltaY":-1},{"type":"mousewheel","time":5516,"x":264,"y":431,"deltaY":-1},{"type":"mousemove","time":6041,"x":263,"y":431},{"type":"mousemove","time":6253,"x":198,"y":509},{"type":"mousemove","time":6459,"x":169,"y":550},{"type":"mousemove","time":6669,"x":136,"y":567},{"type":"mousemove","time":6873,"x":118,"y":570},{"type":"mousemove","time":7083,"x":117,"y":570},{"type":"mousedown","time":7534,"x":117,"y":570},{"type":"mousemove","time":7544,"x":117,"y":570},{"type":"mousemove","time":7752,"x":146,"y":570},{"type":"mousemove","time":7957,"x":167,"y":570},{"type":"mousemove","time":8157,"x":184,"y":570},{"type":"mousemove","time":8368,"x":194,"y":569},{"type":"mousemove","time":8574,"x":206,"y":568},{"type":"mousemove","time":8788,"x":207,"y":568},{"type":"mousemove","time":9000,"x":205,"y":568},{"type":"mouseup","time":9515,"x":205,"y":568},{"time":9516,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":9525,"x":223,"y":570},{"type":"mousemove","time":9735,"x":493,"y":570},{"type":"mousemove","time":9940,"x":580,"y":564},{"type":"mousemove","time":10140,"x":599,"y":568},{"type":"mousemove","time":10351,"x":611,"y":565},{"type":"mousemove","time":10573,"x":608,"y":566},{"type":"mousemove","time":10783,"x":607,"y":566},{"type":"mousemove","time":10857,"x":606,"y":566},{"type":"mousemove","time":11067,"x":606,"y":566},{"type":"mousedown","time":11200,"x":606,"y":566},{"type":"mousemove","time":11210,"x":606,"y":566},{"type":"mousemove","time":11419,"x":538,"y":572},{"type":"mousemove","time":11623,"x":516,"y":572},{"type":"mousemove","time":11823,"x":506,"y":570},{"type":"mousemove","time":12023,"x":514,"y":569},{"type":"mousemove","time":12224,"x":536,"y":567},{"type":"mousemove","time":12424,"x":613,"y":574},{"type":"mousemove","time":12640,"x":615,"y":574},{"type":"mousemove","time":12840,"x":674,"y":561},{"type":"mousemove","time":13041,"x":696,"y":561},{"type":"mousemove","time":13240,"x":716,"y":561},{"type":"mousemove","time":13440,"x":731,"y":559},{"type":"mousemove","time":13640,"x":743,"y":558},{"type":"mousemove","time":13852,"x":749,"y":558},{"type":"mousemove","time":14067,"x":750,"y":558},{"type":"mouseup","time":14087,"x":750,"y":558},{"time":14088,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14099,"x":662,"y":548},{"type":"mousemove","time":14302,"x":166,"y":589},{"type":"mousemove","time":14506,"x":198,"y":564},{"type":"mousemove","time":14707,"x":209,"y":565},{"type":"mousemove","time":14907,"x":208,"y":567},{"type":"mousemove","time":15117,"x":208,"y":567},{"type":"mousedown","time":15367,"x":208,"y":567},{"type":"mousemove","time":15377,"x":208,"y":567},{"type":"mousemove","time":15604,"x":173,"y":569},{"type":"mousemove","time":15809,"x":173,"y":569},{"type":"mousemove","time":16019,"x":150,"y":568},{"type":"mousemove","time":16236,"x":54,"y":565},{"type":"mousemove","time":16449,"x":52,"y":564},{"type":"mouseup","time":16533,"x":52,"y":564},{"time":16534,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16656,"x":242,"y":564},{"type":"mousemove","time":16857,"x":238,"y":562},{"type":"mousemove","time":17057,"x":213,"y":571},{"type":"mousemove","time":17257,"x":211,"y":572},{"type":"mousemove","time":17469,"x":208,"y":572},{"type":"mousedown","time":17733,"x":208,"y":572},{"type":"mousemove","time":17743,"x":208,"y":572},{"type":"mousemove","time":17952,"x":252,"y":576},{"type":"mousemove","time":18157,"x":282,"y":577},{"type":"mousemove","time":18368,"x":346,"y":574},{"type":"mousemove","time":18557,"x":349,"y":574},{"type":"mousemove","time":18757,"x":396,"y":572},{"type":"mousemove","time":18957,"x":435,"y":567},{"type":"mousemove","time":19167,"x":469,"y":563},{"type":"mousemove","time":19373,"x":498,"y":562},{"type":"mousemove","time":19574,"x":556,"y":566},{"type":"mousemove","time":19785,"x":599,"y":559},{"type":"mousemove","time":20001,"x":599,"y":559},{"type":"mousemove","time":20090,"x":599,"y":559},{"type":"mousemove","time":20290,"x":655,"y":561},{"type":"mousemove","time":20490,"x":671,"y":561},{"type":"mousemove","time":20691,"x":686,"y":561},{"type":"mousemove","time":20901,"x":696,"y":560},{"type":"mousemove","time":21107,"x":717,"y":557},{"type":"mousemove","time":21307,"x":738,"y":557},{"type":"mousemove","time":21521,"x":714,"y":561},{"type":"mouseup","time":21719,"x":714,"y":561},{"time":21720,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":21732,"x":663,"y":556},{"type":"mousemove","time":21937,"x":435,"y":551},{"type":"mousemove","time":22140,"x":417,"y":544},{"type":"mousemove","time":22340,"x":412,"y":547},{"type":"mousemove","time":22553,"x":409,"y":551},{"type":"mousedown","time":22802,"x":409,"y":551},{"type":"mousemove","time":22813,"x":410,"y":551},{"type":"mousemove","time":23021,"x":447,"y":551},{"type":"mousemove","time":23223,"x":464,"y":552},{"type":"mousemove","time":23424,"x":481,"y":551},{"type":"mousemove","time":23624,"x":486,"y":551},{"type":"mousemove","time":23836,"x":493,"y":551},{"type":"mousemove","time":24053,"x":503,"y":551},{"type":"mouseup","time":24235,"x":503,"y":551},{"time":24236,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":24246,"x":510,"y":544},{"type":"mousemove","time":24463,"x":617,"y":442},{"type":"mousemove","time":24676,"x":630,"y":427},{"type":"mousemove","time":24885,"x":632,"y":429},{"type":"mousemove","time":25008,"x":628,"y":431},{"type":"mousedown","time":25202,"x":571,"y":457},{"type":"mousemove","time":25241,"x":571,"y":457},{"type":"mouseup","time":25337,"x":571,"y":457},{"time":25338,"delay":400,"type":"screenshot-auto"}],"scrollY":2221.5,"scrollX":0,"timestamp":1769352454788}] \ No newline at end of file From 28e74ef83271362c7c614c21760ab08ca682945d Mon Sep 17 00:00:00 2001 From: 100pah Date: Sun, 25 Jan 2026 23:11:59 +0800 Subject: [PATCH 11/31] fix(pie-on-cartesian): Previously when pie is located on Cartesian, axes extent can not union pie center automatically. Commit 18a23a87585cc8a1575b63dcc4060af5007330fc has supported it. This commit updates test cases. --- test/pie-coordinate-system.html | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/test/pie-coordinate-system.html b/test/pie-coordinate-system.html index c44c74bc52..ca6e1a0f20 100644 --- a/test/pie-coordinate-system.html +++ b/test/pie-coordinate-system.html @@ -36,7 +36,7 @@
-
+
+ + + + + + + + + diff --git a/test/axis.html b/test/axis.html index 72a233812e..2b9c21f024 100644 --- a/test/axis.html +++ b/test/axis.html @@ -21,26 +21,24 @@ + - + + + + -
- + + + + + \ No newline at end of file diff --git a/test/bar-overflow-plot2.html b/test/bar-overflow-plot2.html new file mode 100644 index 0000000000..869a364ec3 --- /dev/null +++ b/test/bar-overflow-plot2.html @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/test/bar-overflow-time-plot.html b/test/bar-overflow-time-plot.html index 057d6c8579..4ea53f1787 100644 --- a/test/bar-overflow-time-plot.html +++ b/test/bar-overflow-time-plot.html @@ -31,93 +31,467 @@ -
-
- - +
+
@@ -220,8 +697,5 @@ - - - \ No newline at end of file diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index b61d378e59..5e7775168f 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -15,6 +15,7 @@ "axis-break-2": 6, "axis-break-3": 4, "axis-break-4": 5, + "axis-data-min-max": 1, "axis-dataset-null": 1, "axis-interval": 3, "axis-interval2": 3, diff --git a/test/runTest/actions/axis-data-min-max.json b/test/runTest/actions/axis-data-min-max.json new file mode 100644 index 0000000000..86a4b370ff --- /dev/null +++ b/test/runTest/actions/axis-data-min-max.json @@ -0,0 +1 @@ +[{"name":"Action 1","ops":[{"type":"mousemove","time":290,"x":513,"y":215},{"type":"mousemove","time":490,"x":335,"y":216},{"type":"mousemove","time":690,"x":252,"y":200},{"type":"mousemove","time":896,"x":224,"y":192},{"type":"mousemove","time":1115,"x":222,"y":188},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":2064,"target":"select"},{"time":2065,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":2140,"x":218,"y":187},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":4908,"target":"select"},{"time":4909,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4991,"x":225,"y":190},{"type":"mousemove","time":5201,"x":225,"y":190},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":7058,"target":"select"},{"time":7059,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7157,"x":225,"y":191},{"type":"mousemove","time":7364,"x":225,"y":191},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":8588,"target":"select"},{"time":8589,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8690,"x":232,"y":192},{"type":"mousemove","time":8898,"x":233,"y":190},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":10172,"target":"select"},{"time":10173,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10223,"x":224,"y":191},{"type":"mousemove","time":10431,"x":225,"y":190},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"0","time":12673,"target":"select"},{"time":12674,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12740,"x":319,"y":146},{"type":"mousemove","time":12940,"x":404,"y":187},{"type":"mousemove","time":13149,"x":399,"y":190},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"1","time":14406,"target":"select"},{"time":14407,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14491,"x":392,"y":189},{"type":"valuechange","selector":"#main_invalid_min_max_permissive>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"0","time":16322,"target":"select"},{"time":16323,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16344,"x":392,"y":172},{"type":"mousemove","time":16548,"x":379,"y":197},{"type":"mousemove","time":16757,"x":364,"y":222},{"type":"mousemove","time":16966,"x":364,"y":223},{"type":"mousemove","time":17657,"x":364,"y":224},{"type":"mousemove","time":17857,"x":308,"y":361},{"type":"mousemove","time":18069,"x":321,"y":358},{"type":"mousemove","time":18273,"x":323,"y":348},{"type":"mousemove","time":18473,"x":359,"y":257},{"type":"mousemove","time":18673,"x":370,"y":237},{"type":"mousemove","time":18873,"x":388,"y":207},{"type":"mousemove","time":19074,"x":392,"y":190},{"type":"mousemove","time":19280,"x":392,"y":190},{"type":"mousemove","time":19323,"x":391,"y":190},{"type":"mousemove","time":19523,"x":239,"y":376},{"type":"mousemove","time":19723,"x":186,"y":419},{"type":"mousemove","time":19932,"x":163,"y":390},{"type":"mousemove","time":20138,"x":344,"y":320},{"type":"mousemove","time":20340,"x":530,"y":331},{"type":"mousemove","time":20540,"x":639,"y":369},{"type":"mousemove","time":20740,"x":541,"y":369},{"type":"mousemove","time":20941,"x":446,"y":312},{"type":"mousemove","time":21156,"x":398,"y":264},{"type":"mousemove","time":21358,"x":425,"y":243},{"type":"mousedown","time":21489,"x":431,"y":234},{"type":"mousemove","time":21563,"x":431,"y":234},{"type":"mouseup","time":21584,"x":431,"y":234},{"time":21585,"delay":400,"type":"screenshot-auto"}],"scrollY":4485,"scrollX":0,"timestamp":1770966085618}] \ No newline at end of file From 1f74fd74926868377d95a92e476c5805e3bf3c21 Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 17 Feb 2026 18:01:39 +0800 Subject: [PATCH 14/31] test(visual): Update markers. --- test/runTest/marks/axis-align-ticks.json | 8 ++++++++ test/runTest/marks/axis-break-4.json | 8 ++++++++ test/runTest/marks/axis-customTicks.json | 10 ++++++++++ test/runTest/marks/axis-data-min-max.json | 10 ++++++++++ test/runTest/marks/axis-interval.json | 8 ++++++++ test/runTest/marks/axis-minorTick.json | 8 ++++++++ test/runTest/marks/axis-style.json | 10 ++++++++++ test/runTest/marks/dataZoom-action.json | 10 ++++++++++ test/runTest/marks/dataZoom-axes.json | 10 ++++++++++ test/runTest/marks/dataZoom-axis-type.json | 10 ++++++++++ test/runTest/marks/dataZoom-cartesian-h.json | 10 ++++++++++ test/runTest/marks/dataZoom-clip.json | 8 ++++++++ test/runTest/marks/dataZoom-rainfall.json | 10 ++++++++++ test/runTest/marks/dataZoom-scatter-hv-polar.json | 10 ++++++++++ test/runTest/marks/dataZoom-scatter-hv.json | 10 ++++++++++ test/runTest/marks/dataZoom-scroll.json | 10 ++++++++++ test/runTest/marks/dataZoom-toolbox.json | 8 ++++++++ test/runTest/marks/dataZoomHighPrecision.json | 10 ++++++++++ 18 files changed, 168 insertions(+) create mode 100644 test/runTest/marks/axis-customTicks.json create mode 100644 test/runTest/marks/axis-data-min-max.json create mode 100644 test/runTest/marks/axis-style.json create mode 100644 test/runTest/marks/dataZoom-action.json create mode 100644 test/runTest/marks/dataZoom-axes.json create mode 100644 test/runTest/marks/dataZoom-axis-type.json create mode 100644 test/runTest/marks/dataZoom-cartesian-h.json create mode 100644 test/runTest/marks/dataZoom-rainfall.json create mode 100644 test/runTest/marks/dataZoom-scatter-hv-polar.json create mode 100644 test/runTest/marks/dataZoom-scatter-hv.json create mode 100644 test/runTest/marks/dataZoom-scroll.json create mode 100644 test/runTest/marks/dataZoomHighPrecision.json diff --git a/test/runTest/marks/axis-align-ticks.json b/test/runTest/marks/axis-align-ticks.json index 87612c2db2..fa3ebce9b9 100644 --- a/test/runTest/marks/axis-align-ticks.json +++ b/test/runTest/marks/axis-align-ticks.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "The diff is introduced by changing the \"alignTicks\" logic and provided a better precision strategy in both ticks and dataZoom. Expected.", + "type": "New Feature", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768470314937 + }, { "link": "https://github.com/apache/echarts/pull/21059", "comment": "Introduce by the `outerBounds` feature that avoid axis name overflowing the canvas by default. There is 5px margin by default. The position moves slightly but acceptable.", diff --git a/test/runTest/marks/axis-break-4.json b/test/runTest/marks/axis-break-4.json index 3daa6c60f7..3262dbb5a3 100644 --- a/test/runTest/marks/axis-break-4.json +++ b/test/runTest/marks/axis-break-4.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "The diff may be introduced by the change of dataZoom auto precision. And the result is acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768476462244 + }, { "link": "https://github.com/apache/echarts/pull/19459", "comment": "", diff --git a/test/runTest/marks/axis-customTicks.json b/test/runTest/marks/axis-customTicks.json new file mode 100644 index 0000000000..9ab3d26bf0 --- /dev/null +++ b/test/runTest/marks/axis-customTicks.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21352", + "comment": "bug fix.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768476700263 + } +] \ No newline at end of file diff --git a/test/runTest/marks/axis-data-min-max.json b/test/runTest/marks/axis-data-min-max.json new file mode 100644 index 0000000000..8018f0c7d1 --- /dev/null +++ b/test/runTest/marks/axis-data-min-max.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/20838", + "comment": "new feature.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768476867683 + } +] \ No newline at end of file diff --git a/test/runTest/marks/axis-interval.json b/test/runTest/marks/axis-interval.json index 3029afa82d..c53c2423cb 100644 --- a/test/runTest/marks/axis-interval.json +++ b/test/runTest/marks/axis-interval.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/pull/21430", + "comment": "The diff may be introduced by the change of dataZoom auto precision. And the result is acceptable.", + "type": "New Feature", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1770820941383 + }, { "link": "https://github.com/apache/echarts/pull/21059", "comment": "Introduce by the `outerBounds` feature that avoid axis name overflowing the canvas by default.", diff --git a/test/runTest/marks/axis-minorTick.json b/test/runTest/marks/axis-minorTick.json index c209faa766..5beef7d863 100644 --- a/test/runTest/marks/axis-minorTick.json +++ b/test/runTest/marks/axis-minorTick.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "The change is introduced by the change of dataZoom auto precision. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768525120932 + }, { "link": "https://github.com/apache/echarts/pull/21059", "comment": "This is some edge cases that detect the overlap between the min/maxLabel the secondary label. #21059 changes the \"margin\" of text and affects the result slightly, but acceptable.", diff --git a/test/runTest/marks/axis-style.json b/test/runTest/marks/axis-style.json new file mode 100644 index 0000000000..7e2bd6f271 --- /dev/null +++ b/test/runTest/marks/axis-style.json @@ -0,0 +1,10 @@ +[ + { + "link": "", + "comment": "Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768525163909 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-action.json b/test/runTest/marks/dataZoom-action.json new file mode 100644 index 0000000000..265f24fad2 --- /dev/null +++ b/test/runTest/marks/dataZoom-action.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768550247098 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-axes.json b/test/runTest/marks/dataZoom-axes.json new file mode 100644 index 0000000000..2f596e5dee --- /dev/null +++ b/test/runTest/marks/dataZoom-axes.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768550448459 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-axis-type.json b/test/runTest/marks/dataZoom-axis-type.json new file mode 100644 index 0000000000..e05f016585 --- /dev/null +++ b/test/runTest/marks/dataZoom-axis-type.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768550897283 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-cartesian-h.json b/test/runTest/marks/dataZoom-cartesian-h.json new file mode 100644 index 0000000000..58687be13f --- /dev/null +++ b/test/runTest/marks/dataZoom-cartesian-h.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768550928066 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-clip.json b/test/runTest/marks/dataZoom-clip.json index 2f7fdb22e5..9d8a93ef95 100644 --- a/test/runTest/marks/dataZoom-clip.json +++ b/test/runTest/marks/dataZoom-clip.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551693040 + }, { "link": "https://github.com/apache/echarts/commit/9f4dfcfc2209ac8b7fd4cb9fb30984a510de21d2", "comment": "", diff --git a/test/runTest/marks/dataZoom-rainfall.json b/test/runTest/marks/dataZoom-rainfall.json new file mode 100644 index 0000000000..31b78c9fe8 --- /dev/null +++ b/test/runTest/marks/dataZoom-rainfall.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551732809 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-scatter-hv-polar.json b/test/runTest/marks/dataZoom-scatter-hv-polar.json new file mode 100644 index 0000000000..710aab9bf8 --- /dev/null +++ b/test/runTest/marks/dataZoom-scatter-hv-polar.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551790705 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-scatter-hv.json b/test/runTest/marks/dataZoom-scatter-hv.json new file mode 100644 index 0000000000..bae52789fe --- /dev/null +++ b/test/runTest/marks/dataZoom-scatter-hv.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551771488 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-scroll.json b/test/runTest/marks/dataZoom-scroll.json new file mode 100644 index 0000000000..051bb0ddc8 --- /dev/null +++ b/test/runTest/marks/dataZoom-scroll.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551813171 + } +] \ No newline at end of file diff --git a/test/runTest/marks/dataZoom-toolbox.json b/test/runTest/marks/dataZoom-toolbox.json index 6547f99e6d..d73c770f12 100644 --- a/test/runTest/marks/dataZoom-toolbox.json +++ b/test/runTest/marks/dataZoom-toolbox.json @@ -1,4 +1,12 @@ [ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551928569 + }, { "link": "https://github.com/apache/echarts/pull/21059", "comment": "Introduced by the `outerBounds` feature, which avoids axis name overflowing the canvas by default.", diff --git a/test/runTest/marks/dataZoomHighPrecision.json b/test/runTest/marks/dataZoomHighPrecision.json new file mode 100644 index 0000000000..2b41e234f9 --- /dev/null +++ b/test/runTest/marks/dataZoomHighPrecision.json @@ -0,0 +1,10 @@ +[ + { + "link": "https://github.com/apache/echarts/issues/21430", + "comment": "Caused by the change of dataZoom auto precision and make axis precision consistent with dataZoom. Acceptable.", + "type": "Others", + "markedBy": "100pah", + "lastVersion": "6.0.0", + "markTime": 1768551994317 + } +] \ No newline at end of file From bdec91e39cffe42ff3f8883e8aa01d3cbf969905 Mon Sep 17 00:00:00 2001 From: 100pah Date: Wed, 18 Feb 2026 01:12:32 +0800 Subject: [PATCH 15/31] refactor: Remove the default value of number round due to its unreasonable and error-prone for small float number. --- src/chart/gauge/GaugeView.ts | 4 ++-- src/chart/helper/Line.ts | 4 +++- src/coord/Axis.ts | 8 ++++---- src/export/api/number.ts | 2 +- src/util/number.ts | 25 +++++++++++++++++++------ 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/chart/gauge/GaugeView.ts b/src/chart/gauge/GaugeView.ts index 1df6458a14..d7465e8a83 100644 --- a/src/chart/gauge/GaugeView.ts +++ b/src/chart/gauge/GaugeView.ts @@ -22,7 +22,7 @@ import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import {createTextStyle, setLabelValueAnimation, animateLabelValue} from '../../label/labelStyle'; import ChartView from '../../view/Chart'; -import {parsePercent, round, linearMap} from '../../util/number'; +import {parsePercent, round, linearMap, DEFAULT_PRECISION_FOR_ROUNDING_ERROR} from '../../util/number'; import GaugeSeriesModel, { GaugeDataItemOption } from './GaugeSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; @@ -272,7 +272,7 @@ class GaugeView extends ChartView { const distance = labelModel.get('distance') + splitLineDistance; const label = formatLabel( - round(i / splitNumber * (maxVal - minVal) + minVal), + round(i / splitNumber * (maxVal - minVal) + minVal, DEFAULT_PRECISION_FOR_ROUNDING_ERROR), labelModel.get('formatter') ); const autoColor = getColor(i / splitNumber); diff --git a/src/chart/helper/Line.ts b/src/chart/helper/Line.ts index 07748206ab..647b5c1d3a 100644 --- a/src/chart/helper/Line.ts +++ b/src/chart/helper/Line.ts @@ -303,7 +303,9 @@ class Line extends graphic.Group { defaultText: (rawVal == null ? lineData.getName(idx) : isFinite(rawVal) - ? round(rawVal) + // PENDING: the `rawVal` is not supposed to be rounded. But this rounding was introduced + // in the early stages, so changing it would likely be breaking. + ? round(rawVal, 10) : rawVal) + '' }); const label = this.getTextContent() as InnerLineLabel; diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index 6f38a191bf..af3721ee12 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -28,12 +28,12 @@ import { createAxisLabelsComputingContext, } from './axisTickLabelBuilder'; import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; -import { DimensionName, NullUndefined, ScaleDataValue, ScaleTick } from '../util/types'; +import { DimensionName, ScaleDataValue, ScaleTick } from '../util/types'; import OrdinalScale from '../scale/Ordinal'; import Model from '../model/Model'; import { AxisBaseOption, CategoryAxisBaseOption, OptionAxisType } from './axisCommonTypes'; import { AxisBaseModel } from './AxisBaseModel'; -import { isIntervalOrTimeScale, isOrdinalScale } from '../scale/helper'; +import { isOrdinalScale } from '../scale/helper'; const NORMALIZED_EXTENT = [0, 1] as [number, number]; @@ -351,8 +351,8 @@ function fixOnBandTicksCoords( function littleThan(a: number, b: number): boolean { // Avoid rounding error cause calculated tick coord different with extent. // It may cause an extra unnecessary tick added. - a = round(a); - b = round(b); + a = round(a, 10); + b = round(b, 10); return inverse ? a > b : a < b; } } diff --git a/src/export/api/number.ts b/src/export/api/number.ts index 40b130d258..835ca1563f 100644 --- a/src/export/api/number.ts +++ b/src/export/api/number.ts @@ -19,7 +19,7 @@ export { linearMap, - round, + roundLegacy as round, asc, getPrecision, getPrecisionSafe, diff --git a/src/util/number.ts b/src/util/number.ts index c7484e04cb..e5fa52256f 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -36,6 +36,9 @@ const RADIAN_EPSILON = 1e-4; // the ES3~ES6 spec (0 <= n <= 20) for backward and cross-platform compatibility. const TO_FIXED_SUPPORTED_PRECISION_MAX = 20; +// For rounding error like `2.9999999999999996`, with respect to IEEE754 64bit float. +export const DEFAULT_PRECISION_FOR_ROUNDING_ERROR = 14; + function _trim(str: string): string { return str.replace(/^\s+|\s+$/g, ''); } @@ -190,14 +193,14 @@ export function parsePositionSizeOption(option: unknown, percentBase: number, pe * Since: ` quantityExponent(val) = floor(log10(abs(val))) ` * Hence: ` precision ~= floor(EXP52B10 - 1 - quantityExponent(val)) */ -export function round(x: number | string, precision?: number): number; +export function round(x: number | string, precision: number): number; export function round(x: number | string, precision: number, returnStr: false): number; export function round(x: number | string, precision: number, returnStr: true): string; -export function round(x: number | string, precision?: number, returnStr?: boolean): string | number { - if (precision == null) { - // FIXME: the default precision should not be provided, since there is no universally adaptable - // precision. The caller need to input a precision according to the scenarios. - precision = 10; +export function round(x: number | string, precision: number, returnStr?: boolean): string | number { + if (__DEV__) { + // NOTICE: We should not provided a default precision, since there is no universally adaptable + // precision. The caller need to input a precision according to the scenarios. + zrUtil.assert(precision != null); } if (isNaN(precision)) { // precision utils (such as getAcceptableTickPrecision) may return NaN. @@ -210,6 +213,16 @@ export function round(x: number | string, precision?: number, returnStr?: boolea return (returnStr ? x : +x); } +export function roundLegacy(x: number | string, precision?: number): number; +export function roundLegacy(x: number | string, precision: number, returnStr: false): number; +export function roundLegacy(x: number | string, precision: number, returnStr: true): string; +export function roundLegacy(x: number | string, precision?: number, returnStr?: boolean): string | number { + if (precision == null) { + precision = 10; + } + return round(x, precision, returnStr as any); +} + /** * Inplacd asc sort arr. * The input arr will be modified. From 52ceb924aaa4806fb50502e593871b075c81449d Mon Sep 17 00:00:00 2001 From: 100pah Date: Thu, 19 Feb 2026 00:58:54 +0800 Subject: [PATCH 16/31] fix: (1) Fix dataZoom AxisProxy can not be cleared when dataZoom option changed. (2) Fix onZero on double value axis and dataZoom is applied on a base axis - onZero should be disabled by default. --- src/component/dataZoom/AxisProxy.ts | 15 ++++++- src/component/dataZoom/DataZoomModel.ts | 1 - src/component/dataZoom/SliderZoomModel.ts | 6 ++- src/component/dataZoom/dataZoomProcessor.ts | 23 +++++----- src/component/dataZoom/helper.ts | 42 +++++++++++++---- src/coord/Axis.ts | 6 +-- src/coord/AxisBaseModel.ts | 2 +- src/coord/axisHelper.ts | 27 ++++++++++- src/coord/cartesian/Cartesian2D.ts | 4 +- src/coord/cartesian/Grid.ts | 26 ++++++----- src/core/echarts.ts | 4 +- src/layout/barGrid.ts | 6 +-- src/util/model.ts | 41 +++++++++++++---- src/util/number.ts | 2 + test/bar-overflow-plot2.html | 50 ++++++++++++++++----- 15 files changed, 190 insertions(+), 65 deletions(-) diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 57f034a7cd..3bcf7a23a1 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -35,6 +35,7 @@ import { isOrdinalScale, isTimeScale } from '../../scale/helper'; import { AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoReallyCreate, } from '../../coord/scaleRawExtentInfo'; +import { suppressOnAxisZero } from '../../coord/axisHelper'; interface MinMaxSpan { @@ -56,6 +57,8 @@ interface AxisProxyWindow { } /** + * NOTICE: Its lifetime is different from `Axis` instance. It is recreated in each run of "ec prepare". + * * Operate single axis. * One axis can only operated by one axis operator. * Different dataZoomModels may be defined to operate the same axis. @@ -156,7 +159,8 @@ class AxisProxy { const dataExtent = this._dataExtent; const axis = this.getAxisModel().axis; const scale = axis.scale; - const rangePropMode = this._dataZoomModel.getRangePropMode(); + const dataZoomModel = this._dataZoomModel; + const rangePropMode = dataZoomModel.getRangePropMode(); const percentExtent = [0, 100]; const percentWindow = [] as unknown as [number, number]; const valueWindow = [] as unknown as [number, number]; @@ -262,6 +266,13 @@ class AxisProxy { const pxSpan = mathAbs(pxExtent[1] - pxExtent[0]); const precision = isScaleOrdinalOrTime ? 0 + // NOTICE: We deliberately do not allow specifying this precision by users, until real requirements + // occur. Otherwise, unnecessary complexity and bad case may be introduced. A small precision may + // cause the rounded ends overflow the expected min/max significantly. And this precision effectively + // determines the size of a roaming step, and a big step would likely constantly cut through series + // shapes in an unexpected place and cause visual artifacts (e.g., for bar series). Although + // theroetically that defect can be resolved by introducing extra spaces between axis min/max tick + // and axis boundary (see `SCALE_EXTENT_KIND_MAPPING`), it's complicated and unnecessary. : getAcceptableTickPrecision(valueWindow, pxSpan, 0.5); each([[0, mathCeil], [1, mathFloor]] as const, function ([idx, ceilOrFloor]) { if (!needRound[idx] || !isFinite(precision)) { @@ -324,6 +335,8 @@ class AxisProxy { const axis = this.getAxisModel().axis; scaleRawExtentInfoReallyCreate(this.ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); + suppressOnAxisZero(axis, {dz: true}); + const rawExtentInfo = axis.scale.rawExtentInfo; this._dataExtent = rawExtentInfo.makeForZoom(); diff --git a/src/component/dataZoom/DataZoomModel.ts b/src/component/dataZoom/DataZoomModel.ts index c83e166cbc..52d93dc3a3 100644 --- a/src/component/dataZoom/DataZoomModel.ts +++ b/src/component/dataZoom/DataZoomModel.ts @@ -24,7 +24,6 @@ import { LayoutOrient, ComponentOption, LabelOption, - NullUndefined } from '../../util/types'; import Model from '../../model/Model'; import GlobalModel from '../../model/Global'; diff --git a/src/component/dataZoom/SliderZoomModel.ts b/src/component/dataZoom/SliderZoomModel.ts index 80d206842a..53f2d9a54f 100644 --- a/src/component/dataZoom/SliderZoomModel.ts +++ b/src/component/dataZoom/SliderZoomModel.ts @@ -107,7 +107,11 @@ export interface SliderDataZoomOption * Height of handle rect. Can be a percent string relative to the slider height. */ moveHandleSize?: number - + /** + * The precision only used on displayed labels. + * NOTICE: Specifying the "value precision" or "roaming step" is not allowed. + * `getAcceptableTickPrecision` is used for that. See `AxisProxy` for reasons. + */ labelPrecision?: number | 'auto' labelFormatter?: string | ((value: number, valueStr: string) => string) diff --git a/src/component/dataZoom/dataZoomProcessor.ts b/src/component/dataZoom/dataZoomProcessor.ts index d64269295a..96033e38dd 100644 --- a/src/component/dataZoom/dataZoomProcessor.ts +++ b/src/component/dataZoom/dataZoomProcessor.ts @@ -20,9 +20,12 @@ import {createHashMap, each} from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; import DataZoomModel from './DataZoomModel'; -import { getAxisMainType, DataZoomAxisDimension, DataZoomExtendedAxisBaseModel, getAlignTo } from './helper'; +import { + getAxisMainType, DataZoomAxisDimension, getAlignTo, getAxisProxyFromModel, setAxisProxyToModel +} from './helper'; import AxisProxy from './AxisProxy'; import { StageHandler } from '../../util/types'; +import { AxisBaseModel } from '../../coord/AxisBaseModel'; const dataZoomProcessor: StageHandler = { @@ -36,31 +39,27 @@ const dataZoomProcessor: StageHandler = { cb: ( axisDim: DataZoomAxisDimension, axisIndex: number, - axisModel: DataZoomExtendedAxisBaseModel, + axisModel: AxisBaseModel, dataZoomModel: DataZoomModel ) => void ) { ecModel.eachComponent('dataZoom', function (dataZoomModel: DataZoomModel) { dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) { const axisModel = ecModel.getComponent(getAxisMainType(axisDim), axisIndex); - cb(axisDim, axisIndex, axisModel as DataZoomExtendedAxisBaseModel, dataZoomModel); + cb(axisDim, axisIndex, axisModel as AxisBaseModel, dataZoomModel); }); }); } // FIXME: it brings side-effect to `getTargetSeries`. - // Prepare axis proxies. - eachAxisModel(function (axisDim, axisIndex, axisModel, dataZoomModel) { - // dispose all last axis proxy, in case that some axis are deleted. - axisModel.__dzAxisProxy = null; - }); const proxyList: AxisProxy[] = []; eachAxisModel(function (axisDim, axisIndex, axisModel, dataZoomModel) { // Different dataZooms may control the same axis. In that case, // an axisProxy serves both of them. - if (!axisModel.__dzAxisProxy) { + if (!getAxisProxyFromModel(axisModel)) { // Use the first dataZoomModel as the main model of axisProxy. - axisModel.__dzAxisProxy = new AxisProxy(axisDim, axisIndex, dataZoomModel, ecModel); - proxyList.push(axisModel.__dzAxisProxy); + const axisProxy = new AxisProxy(axisDim, axisIndex, dataZoomModel, ecModel); + proxyList.push(axisProxy); + setAxisProxyToModel(axisModel, axisProxy); } }); @@ -135,4 +134,4 @@ const dataZoomProcessor: StageHandler = { } }; -export default dataZoomProcessor; \ No newline at end of file +export default dataZoomProcessor; diff --git a/src/component/dataZoom/helper.ts b/src/component/dataZoom/helper.ts index e2009cb764..d3170ea2bf 100644 --- a/src/component/dataZoom/helper.ts +++ b/src/component/dataZoom/helper.ts @@ -25,6 +25,8 @@ import SeriesModel from '../../model/Series'; import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import type AxisProxy from './AxisProxy'; +import { getCachePerECPrepare, GlobalModelCachePerECPrepare, makeInner } from '../../util/model'; +import type ComponentModel from '../../model/Component'; export interface DataZoomPayloadBatchItem { @@ -37,17 +39,13 @@ export interface DataZoomPayloadBatchItem { export interface DataZoomReferCoordSysInfo { model: CoordinateSystemHostModel; - // Notice: if two dataZooms refer the same coordinamte system model, - // (1) The axis they refered may different + // Notice: if two dataZooms refer the same coordinate system model, + // (1) The axis they referred may different // (2) The sequence the axisModels matters, may different in // different dataZooms. axisModels: AxisBaseModel[]; } -export type DataZoomExtendedAxisBaseModel = AxisBaseModel & { - __dzAxisProxy: AxisProxy -}; - export const DATA_ZOOM_AXIS_DIMENSIONS = [ 'x', 'y', 'radius', 'angle', 'single' ] as const; @@ -61,6 +59,12 @@ type DataZoomAxisIdPropName = 'xAxisId' | 'yAxisId' | 'radiusAxisId' | 'angleAxisId' | 'singleAxisId'; export type DataZoomCoordSysMainType = 'polar' | 'grid' | 'singleAxis'; +const ecModelCacheInner = makeInner<{ + axisProxyMap: AxisProxyMap; +}, GlobalModelCachePerECPrepare>(); + +type AxisProxyMap = HashMap; + // Supported coords. // FIXME: polar has been broken (but rarely used). const SERIES_COORDS = ['cartesian2d', 'polar', 'singleAxis'] as const; @@ -211,8 +215,28 @@ export function collectReferCoordSysModelInfo(dataZoomModel: DataZoomModel): { return coordSysInfoWrap; } -export function getAxisProxyFromModel(axisModel: AxisBaseModel): AxisProxy | NullUndefined { - return axisModel && (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; +function ensureAxisProxyMap(ecModel: GlobalModel): AxisProxyMap { + // Consider some axes may be deleted, and dataZoom options may be changed at and only at each run of + // "ec prepare", we save axis proxies to a cache that is auto-cleared for each run of "ec prepare". + const store = ecModelCacheInner(getCachePerECPrepare(ecModel)); + return store.axisProxyMap || (store.axisProxyMap = createHashMap()); +} + +export function getAxisProxyFromModel(axisModel: AxisBaseModel | NullUndefined): AxisProxy | NullUndefined { + if (!axisModel) { + return; + } + if (__DEV__) { + assert(axisModel.ecModel); + } + return ensureAxisProxyMap(axisModel.ecModel).get(axisModel.uid); +} + +export function setAxisProxyToModel(axisModel: AxisBaseModel, axisProxy: AxisProxy): void { + if (__DEV__) { + assert(axisModel.ecModel); + } + ensureAxisProxyMap(axisModel.ecModel).set(axisModel.uid, axisProxy); } /** @@ -221,7 +245,7 @@ export function getAxisProxyFromModel(axisModel: AxisBaseModel): AxisProxy | Nul * then do not input it into `AxisProxy#reset`. */ export function getAlignTo(dataZoomModel: DataZoomModel, axisProxy: AxisProxy): AxisProxy | NullUndefined { - const alignToAxis = axisProxy.getAxisModel().axis.alignTo; + const alignToAxis = axisProxy.getAxisModel().axis.__alignTo; return ( alignToAxis && dataZoomModel.getAxisProxy( alignToAxis.dim as DataZoomAxisDimension, diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index af3721ee12..d360188344 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -82,10 +82,8 @@ class Axis { // `inverse` can be inferred by `extent` unless `extent[0] === extent[1]`. inverse: AxisBaseOption['inverse'] = false; - // Injected outside - alignTo: Axis; - // Injected outside. - suggestNotOnZeroOfMe: boolean; + // To be injected outside. May change - do not use it outside of echarts. + __alignTo: Axis; constructor(dim: DimensionName, scale: Scale, extent: [number, number]) { diff --git a/src/coord/AxisBaseModel.ts b/src/coord/AxisBaseModel.ts index 9b482f0cb0..1909edb25f 100644 --- a/src/coord/AxisBaseModel.ts +++ b/src/coord/AxisBaseModel.ts @@ -31,5 +31,5 @@ export interface AxisBaseModel, AxisModelExtendedInCreator { - axis: Axis + axis: Axis; } \ No newline at end of file diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index c7e23a375d..36fbb39ab4 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -50,11 +50,21 @@ import { extentDiffers, isLogScale, isOrdinalScale } from '../scale/helper'; import { AxisModelExtendedInCreator } from './axisModelCreator'; -import { initExtentForUnion } from '../util/model'; +import { initExtentForUnion, makeInner } from '../util/model'; import { ComponentModel } from '../echarts.simple'; import { SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_OUT_OF_BREAK } from '../scale/scaleMapper'; +const axisInner = makeInner<{ + noOnMyZero: SuppressOnAxisZeroReason; +}, Axis>(); + +type SuppressOnAxisZeroReason = { + dz?: boolean; + base?: boolean +}; + + export function determineAxisType( model: Model> ): OptionAxisType { @@ -137,6 +147,21 @@ export function ifAxisCrossZero(axis: Axis) { return !((min > 0 && max > 0) || (min < 0 && max < 0)); } +export function suppressOnAxisZero(axis: Axis, reason: Partial): void { + zrUtil.defaults(axisInner(axis).noOnMyZero || (axisInner(axis).noOnMyZero = {}), reason); +} + +/** + * `true`: Prevent orthoganal axes from positioning at the zero point of this axis. + */ +export function isOnAxisZeroSuppressed(axis: Axis): boolean { + const dontOnAxisZero = axisInner(axis).noOnMyZero; + // Empirically, onZero causes weired effect when dataZoom is used on an "base axis". Consider + // bar series as an example. And also consider when `SCALE_EXTENT_KIND_MAPPING` is used, where + // the axis line is likely to cross the series shapes unexpectedly. + return dontOnAxisZero && dontOnAxisZero.dz && dontOnAxisZero.base; +} + /** * @param axis * @return Label formatter function. diff --git a/src/coord/cartesian/Cartesian2D.ts b/src/coord/cartesian/Cartesian2D.ts index d50379275e..fc56cc2b54 100644 --- a/src/coord/cartesian/Cartesian2D.ts +++ b/src/coord/cartesian/Cartesian2D.ts @@ -88,9 +88,11 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { } /** - * Base axis will be used on stacking. + * Base axis will be used on stacking and series such as 'bar', 'pictorialBar', etc. */ getBaseAxis(): Axis2D { + // PENGING: Should we allow bar series to specify a base axis when + // both axes are type "value", rather than force to xAxis? return this.getAxesByScale('ordinal')[0] || this.getAxesByScale('time')[0] || this.getAxis('x'); diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index f3e698d19f..1c18aae1a7 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -32,6 +32,8 @@ import { shouldAxisShow, retrieveAxisBreaksOption, determineAxisType, + suppressOnAxisZero, + isOnAxisZeroSuppressed, } from '../../coord/axisHelper'; import Cartesian2D, {cartesian2DDimensions} from './Cartesian2D'; import Axis2D from './Axis2D'; @@ -74,7 +76,6 @@ import { AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoEnableBoxCoordSysUsage, scaleRawExtentInfoReallyCreate, scaleRawExtentInfoRequireCreate } from '../scaleRawExtentInfo'; -import { SCALE_EXTENT_KIND_MAPPING } from '../../scale/scaleMapper'; import { hasBreaks } from '../../scale/break'; @@ -144,7 +145,7 @@ class Grid implements CoordinateSystemMaster { for (let i = axesIndices.length - 1; i >= 0; i--) { // Reverse order const axis = axes[+axesIndices[i]]; - if (axis.alignTo) { + if (axis.__alignTo) { axisNeedsAlign.push(axis); } else { @@ -152,13 +153,13 @@ class Grid implements CoordinateSystemMaster { } }; each(axisNeedsAlign, axis => { - if (incapableOfAlignNeedFallback(axis, axis.alignTo as Axis2D)) { + if (incapableOfAlignNeedFallback(axis, axis.__alignTo as Axis2D)) { scaleCalcNice(axis); } else { scaleCalcAlign( axis, - axis.alignTo.scale as IntervalScale | LogScale + axis.__alignTo.scale as IntervalScale | LogScale ); } }); @@ -445,6 +446,8 @@ class Grid implements CoordinateSystemMaster { cartesian.addAxis(xAxis); cartesian.addAxis(yAxis); + + suppressOnAxisZero(cartesian.getBaseAxis(), {base: true}); }); }); @@ -666,17 +669,18 @@ function fixAxisOnZero( } } +/** + * CAVEAT: Must not be called before `CoordinateSystem#update` due to `__dontOnMyZero`. + */ function canOnZeroToAxis( onZeroOption: AxisBaseOptionCommon['axisLine']['onZero'], axis: Axis2D ): boolean { + // PENDING: Historical behavior: `onZero` on 'category' and 'time' axis are always disabled + // even if ec option gives `onZero: true`. let can = axis && axis.type !== 'category' && axis.type !== 'time' && ifAxisCrossZero(axis); - if (can && onZeroOption === 'auto') { - if (axis.scale.getExtentUnsafe(SCALE_EXTENT_KIND_MAPPING, null)) { - // Empirically, onZero is inappropriate when `SCALE_EXTENT_KIND_MAPPING` is - // used - the axis line is likely to cross the series shapes unexpectedly. - can = false; - } + if (can && onZeroOption === 'auto' && isOnAxisZeroSuppressed(axis)) { + can = false; } // falsy value of `onZeroOption` has been handled in the previous logic. return can; @@ -722,7 +726,7 @@ function prepareAlignToInCoordSysCreate(axes: Record): void { } if (alignTo) { each(axisNeedsAlign, function (axis) { - axis.alignTo = alignTo; + axis.__alignTo = alignTo; }); } } diff --git a/src/core/echarts.ts b/src/core/echarts.ts index c199e60cbf..18efa5c25b 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -1577,6 +1577,8 @@ class ECharts extends Eventful { private static internalField = (function () { prepare = function (ecIns: ECharts): void { + modelUtil.resetCachePerECPrepare(ecIns._model); + const scheduler = ecIns._scheduler; scheduler.restorePipelines(ecIns._model); @@ -1802,7 +1804,7 @@ class ECharts extends Eventful { return; } - modelUtil.resetCachePerECUpdate(ecModel); + modelUtil.resetCachePerECFullUpdate(ecModel); ecModel.setUpdatePayload(payload); scheduler.restoreData(ecModel, payload); diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index e16641fb1e..dcba7aa30e 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -28,7 +28,7 @@ import { StageHandler, NullUndefined } from '../util/types'; import { createFloat32Array } from '../util/vendor'; import { extentHasValue, - getCachePerECUpdate, GlobalModelCachePerECUpdate, initExtentForUnion, + getCachePerECFullUpdate, GlobalModelCachePerECFullUpdate, initExtentForUnion, isValidNumberForExtent, makeCallOnlyOnce, makeInner, unionExtentFromNumber, } from '../util/model'; @@ -48,7 +48,7 @@ import type Scale from '../scale/Scale'; const ecModelCacheInner = makeInner<{ layoutPre: BarGridLayoutPre; -}, GlobalModelCachePerECUpdate>(); +}, GlobalModelCachePerECFullUpdate>(); // Record of layout preparation by series sub type. type BarGridLayoutPre = Partial>; @@ -176,7 +176,7 @@ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultFo function ensureLayoutPre( ecModel: GlobalModel, seriesType: BaseBarSeriesSubType ): BarGridLayoutPreOnSeriesType { - const ecCache = ecModelCacheInner(getCachePerECUpdate(ecModel)); + const ecCache = ecModelCacheInner(getCachePerECFullUpdate(ecModel)); const layoutPre = ecCache.layoutPre || (ecCache.layoutPre = {}); return layoutPre[seriesType] || (layoutPre[seriesType] = { axes: [], axisMap: {}, seriesReady: false diff --git a/src/util/model.ts b/src/util/model.ts index b63cf293f1..67ef0282d2 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -1269,22 +1269,47 @@ let onceUniqueIndex = getRandomIdBase(); const ecModelCacheInner = makeInner<{ - perECUpdate: GlobalModelCachePerECUpdate; + fullUpdate: GlobalModelCachePerECFullUpdate; + prepare: GlobalModelCachePerECPrepare; }, GlobalModel>(); +export type GlobalModelCachePerECPrepare = {__: 'prepare'}; // Nominal to distinguish. +export type GlobalModelCachePerECFullUpdate = {__: 'fullUpdate'}; // Nominal to distinguish. + /** - * Reset on each time `updateMethods.update` (i.e., full update) is called. - * It is mainly used for cache. + * CAVEAT: Can only be called by `echarts.ts` */ -export type GlobalModelCachePerECUpdate = {}; +export function resetCachePerECPrepare(ecModel: GlobalModel): void { + ecModelCacheInner(ecModel).prepare = {} as GlobalModelCachePerECPrepare; +} /** * CAVEAT: Can only be called by `echarts.ts` */ -export function resetCachePerECUpdate(ecModel: GlobalModel): void { - ecModelCacheInner(ecModel).perECUpdate = {}; +export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { + ecModelCacheInner(ecModel).fullUpdate = {} as GlobalModelCachePerECFullUpdate; } -export function getCachePerECUpdate(ecModel: GlobalModel): GlobalModelCachePerECUpdate { - return ecModelCacheInner(ecModel).perECUpdate; +/** + * The cache is auto cleared at the begining of a run of "ec prepare". + * + * NOTICE: + * - It can be only called at "ec prepare" stage, such as, + * - Do not call it in processor `getTargetSeries` methods. + * - Do not call it in component/series model `init`/`mergeOption`/`optionUpdated`/`getData` methods. + * - "ec prepare" is not necessarily called before each "ec full update". + */ +export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { + return ecModelCacheInner(ecModel).prepare; +} + +/** + * The cache is auto cleared at the begining of a run of "ec full update". + * + * NOTICE: + * - Do not call it at "ec prepare" stage. See `getCachePerECPrepare` for details. + * - All shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. + */ +export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { + return ecModelCacheInner(ecModel).fullUpdate; } diff --git a/src/util/number.ts b/src/util/number.ts index e5fa52256f..84a802dd1b 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -37,6 +37,8 @@ const RADIAN_EPSILON = 1e-4; const TO_FIXED_SUPPORTED_PRECISION_MAX = 20; // For rounding error like `2.9999999999999996`, with respect to IEEE754 64bit float. +// NOTICE: It only works when the expected result is a rational number with low +// precision. See method `round` for details. export const DEFAULT_PRECISION_FOR_ROUNDING_ERROR = 14; function _trim(str: string): string { diff --git a/test/bar-overflow-plot2.html b/test/bar-overflow-plot2.html index 869a364ec3..cec30e22f8 100644 --- a/test/bar-overflow-plot2.html +++ b/test/bar-overflow-plot2.html @@ -42,10 +42,12 @@ // Historically, true by default. yAxisOnZero: 'unspecified', useDataZoom: true, + xAxisShowMinMaxLabel: undefined, + dataZoomLabelPrecision: undefined, }; function createData() { - var MIN_X = 0; + var MIN_X = -0.0025; var MAX_X = 0.0035; var actualMinX = Infinity; var data0 = []; @@ -57,23 +59,22 @@ var dataSet = {bar_a: [], bar_b: [], bar_partially_negative: []}; var minXSet = {bar_a: Infinity, bar_b: Infinity, bar_partially_negative: Infinity}; - for (var i = MIN_X; i <= MAX_X; i += 0.0001) { + for (var i = MIN_X; i <= MAX_X; i = +(i + 0.0001).toFixed(10)) { lastDelta += (Math.random() - 0.5) * 5; last += lastDelta; - addItem( - 'bar_a', i, last - ); - addItem( - 'bar_b', i, Math.max(90, last - Math.random() * 700) - ); - addItem( - 'bar_partially_negative', i - Math.round((MAX_X - MIN_X) / 2), Math.max(70, last + Math.random() * 600) - ); + if (i >= 0) { + addItem('bar_a', i, last); + addItem('bar_b', i, Math.max(90, last - Math.random() * 700)); + } + if (i < 0.0030) { + addItem('bar_partially_negative', i, Math.max(70, last + Math.random() * 600)); + } } function addItem(prop, x, y) { dataSet[prop].push([x, y]); minXSet[prop] = Math.min(minXSet[prop], x); } + console.log(dataSet, minXSet) return {dataSet, minXSet}; } @@ -104,8 +105,12 @@ dataZoom: _ctx.useDataZoom ? [{ type: 'slider', + labelPrecision: _ctx.dataZoomLabelPrecision, + valuePrecision: 4, }, { type: 'inside', + labelPrecision: _ctx.dataZoomLabelPrecision, + valuePrecision: 4, }] : undefined, xAxis: { @@ -113,6 +118,10 @@ splitLine: { show: false }, + axisLabel: { + showMinLabel: _ctx.xAxisShowMinMaxLabel, + showMaxLabel: _ctx.xAxisShowMinMaxLabel, + } }, yAxis: { type: 'value', @@ -153,6 +162,7 @@ var myChart = testHelper.create(echarts, 'main_onZero', { title: [ + 'Bars must not overflow the xAxis.', 'Check yAxis onZero to xAxis (xAxis is value axis).', `series data min value is **${testHelper.printObject(_data.minXSet)}**`, ], @@ -174,6 +184,24 @@ _ctx.useDataZoom = this.value; updateChart(); } + }, { + type: 'select', + text: 'xAxisShowMinMaxLabel:', + values: [_ctx.xAxisShowMinMaxLabel, true, false], + onchange() { + _ctx.xAxisShowMinMaxLabel = this.value; + updateChart(); + } + }, { + type: 'br', + }, { + type: 'select', + text: 'dataZoomLabelPrecision:', + values: [_ctx.dataZoomLabelPrecision, 0, 2, 4, 8], + onchange() { + _ctx.dataZoomLabelPrecision = this.value; + updateChart(); + } }] }); }) From d47ea4ad7bea2aa8f595dcebf1454f1512dfeb98 Mon Sep 17 00:00:00 2001 From: 100pah Date: Thu, 19 Feb 2026 21:04:10 +0800 Subject: [PATCH 17/31] fix(dataZoom): Do not display values outside of effective extent. --- src/component/dataZoom/AxisProxy.ts | 50 ++++++++++----- src/component/dataZoom/SliderZoomView.ts | 79 ++++++++++++++---------- src/coord/scaleRawExtentInfo.ts | 21 +++++-- test/bar-overflow-plot2.html | 1 + 4 files changed, 98 insertions(+), 53 deletions(-) diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 3bcf7a23a1..fee554bcc1 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -34,6 +34,7 @@ import { SINGLE_REFERRING } from '../../util/model'; import { isOrdinalScale, isTimeScale } from '../../scale/helper'; import { AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoReallyCreate, + ScaleRawExtentResultForZoom, } from '../../coord/scaleRawExtentInfo'; import { suppressOnAxisZero } from '../../coord/axisHelper'; @@ -45,20 +46,20 @@ interface MinMaxSpan { maxValueSpan: number } -interface AxisProxyWindow { - value: [number, number]; - percent: [number, number]; +export interface AxisProxyWindow { + // NOTE: May include non-effective portion. + value: number[]; + noZoomEffMM: ScaleRawExtentResultForZoom['noZoomEffMM']; + percent: number[]; // Percent invert from "value window", which may be slightly different from "percent window" due to some // handling such as rounding. The difference may be magnified in cases like "alignTicks", so we use // `percentInverted` in these cases. // But we retain the original input percent in `percent` whenever possible, since they have been used in views. - percentInverted: [number, number]; + percentInverted: number[]; valuePrecision: number; } /** - * NOTICE: Its lifetime is different from `Axis` instance. It is recreated in each run of "ec prepare". - * * Operate single axis. * One axis can only operated by one axis operator. * Different dataZoomModels may be defined to operate the same axis. @@ -69,12 +70,15 @@ class AxisProxy { ecModel: GlobalModel; + // NOTICE: The lifetime of `AxisProxy` instance is different from `Axis` instance. + // It is recreated in each run of "ec prepare". + private _dimName: DataZoomAxisDimension; private _axisIndex: number; private _window: AxisProxyWindow; - private _dataExtent: number[]; + private _extent: ScaleRawExtentResultForZoom; private _minMaxSpan: MinMaxSpan; @@ -156,7 +160,7 @@ class AxisProxy { endValue?: number | string | Date } ): AxisProxyWindow { - const dataExtent = this._dataExtent; + const {noZoomMapMM: dataExtent, noZoomEffMM} = this._extent; const axis = this.getAxisModel().axis; const scale = axis.scale; const dataZoomModel = this._dataZoomModel; @@ -167,13 +171,26 @@ class AxisProxy { let hasPropModeValue; const needRound = [false, false]; + // NOTE: + // The current percentage base calculation strategy: + // - If the window boundary is NOT at 0% or 100%, boundary values are derived from the raw extent + // (series data + axis.min/max; see `ScaleRawExtentInfo['makeForZoom']`). Any subsequent "nice" + // expansion are excluded. + // - If the window boundary is at 0% or 100%, the "nice"-expanded portion is included. + // Pros: + // - The effect may be preferable when users intend to quickly narrow down to data details, + // especially when "nice strategy" excessively expands the extent. + // - It simplifies the logic, otherwise, "nice strategy" would need to be applied twice (full window + // + current window). + // Cons: + // - This strategy causes jitter when switching dataZoom to/from 0%/100% (though generally acceptable). + each(['start', 'end'] as const, function (prop, idx) { let boundPercent = opt[prop]; let boundValue = opt[prop + 'Value' as 'startValue' | 'endValue']; - // Notice: dataZoom is based either on `percentProp` ('start', 'end') or - // on `valueProp` ('startValue', 'endValue'). (They are based on the data extent - // but not min/max of axis, which will be calculated by data window then). + // NOTE: dataZoom is based either on `percentProp` ('start', 'end') or + // on `valueProp` ('startValue', 'endValue'). // The former one is suitable for cases that a dataZoom component controls multiple // axes with different unit or extent, and the latter one is suitable for accurate // zoom by pixel (e.g., in dataZoomSelect). @@ -307,6 +324,7 @@ class AxisProxy { return { value: valueWindow, + noZoomEffMM: noZoomEffMM.slice(), percent: percentWindow, percentInverted: percentInvertedWindow, valuePrecision: precision, @@ -318,7 +336,7 @@ class AxisProxy { * so it is recommended to be called in "process stage" but not "model init * stage". */ - reset(dataZoomModel: DataZoomModel, alignToPercentInverted: [number, number] | NullUndefined) { + reset(dataZoomModel: DataZoomModel, alignToPercentInverted: number[] | NullUndefined) { if (!this.hostedBy(dataZoomModel)) { return; } @@ -338,7 +356,7 @@ class AxisProxy { suppressOnAxisZero(axis, {dz: true}); const rawExtentInfo = axis.scale.rawExtentInfo; - this._dataExtent = rawExtentInfo.makeForZoom(); + this._extent = rawExtentInfo.makeForZoom(); // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); @@ -441,7 +459,7 @@ class AxisProxy { } else { const range: Dictionary<[number, number]> = {}; - range[dim] = valueWindow; + range[dim] = valueWindow as [number, number]; // console.time('select'); seriesData.selectRange(range); @@ -451,7 +469,7 @@ class AxisProxy { } each(dataDims, function (dim) { - seriesData.setApproximateExtent(valueWindow, dim); + seriesData.setApproximateExtent(valueWindow as [number, number], dim); }); }); @@ -463,7 +481,7 @@ class AxisProxy { private _updateMinMaxSpan() { const minMaxSpan = this._minMaxSpan = {} as MinMaxSpan; const dataZoomModel = this._dataZoomModel; - const dataExtent = this._dataExtent; + const dataExtent = this._extent.noZoomMapMM; each(['min', 'max'], function (minMax) { let percentSpan = dataZoomModel.get(minMax + 'Span' as 'minSpan' | 'maxSpan'); diff --git a/src/component/dataZoom/SliderZoomView.ts b/src/component/dataZoom/SliderZoomView.ts index 9cc8a447fb..ef8ddc4d29 100644 --- a/src/component/dataZoom/SliderZoomView.ts +++ b/src/component/dataZoom/SliderZoomView.ts @@ -22,7 +22,7 @@ import * as eventTool from 'zrender/src/core/event'; import * as graphic from '../../util/graphic'; import * as throttle from '../../util/throttle'; import DataZoomView from './DataZoomView'; -import { linearMap, asc, parsePercent, round } from '../../util/number'; +import { linearMap, asc, parsePercent, round, mathMax, mathMin } from '../../util/number'; import * as layout from '../../util/layout'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; @@ -44,8 +44,11 @@ import Displayable from 'zrender/src/graphic/Displayable'; import { createTextStyle } from '../../label/labelStyle'; import SeriesData from '../../data/SeriesData'; import tokens from '../../visual/tokens'; -import type AxisProxy from './AxisProxy'; import { isOrdinalScale, isTimeScale } from '../../scale/helper'; +import { AxisProxyWindow } from './AxisProxy'; +import type Scale from '../../scale/Scale'; +import { SCALE_EXTENT_KIND_EFFECTIVE } from '../../scale/scaleMapper'; + const Rect = graphic.Rect; @@ -827,11 +830,12 @@ class SliderZoomView extends DataZoomView { if (dataZoomModel.get('showDetail')) { const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); + const scale = axisProxy.getAxisModel().axis.scale; if (axisProxy) { const range = this._range; - let dataInterval: [number, number]; + let window: AxisProxyWindow; if (nonRealtime) { // See #4434, data and axis are not processed and reset yet in non-realtime mode. let calcWinInput = {start: range[0], end: range[1]}; @@ -840,15 +844,15 @@ class SliderZoomView extends DataZoomView { const alignToWindow = alignTo.calculateDataWindow(calcWinInput).percentInverted; calcWinInput = {start: alignToWindow[0], end: alignToWindow[1]}; } - dataInterval = axisProxy.calculateDataWindow(calcWinInput).value; + window = axisProxy.calculateDataWindow(calcWinInput); } else { - dataInterval = axisProxy.getWindow().value; + window = axisProxy.getWindow(); } labelTexts = [ - this._formatLabel(dataInterval[0], axisProxy), - this._formatLabel(dataInterval[1], axisProxy) + formatLabel(dataZoomModel, 0, window, scale), + formatLabel(dataZoomModel, 1, window, scale) ]; } } @@ -886,31 +890,6 @@ class SliderZoomView extends DataZoomView { } } - private _formatLabel(value: number, axisProxy: AxisProxy) { - const dataZoomModel = this.dataZoomModel; - const labelFormatter = dataZoomModel.get('labelFormatter'); - - let labelPrecision = dataZoomModel.get('labelPrecision'); - if (labelPrecision == null || labelPrecision === 'auto') { - labelPrecision = axisProxy.getWindow().valuePrecision; - } - - const scale = axisProxy.getAxisModel().axis.scale; - const valueStr = (value == null || isNaN(value)) - ? '' - : (isOrdinalScale(scale) || isTimeScale(scale)) - ? scale.getLabel({value: Math.round(value)}) - : isFinite(labelPrecision) - ? round(value, labelPrecision, true) - : value + ''; - - return isFunction(labelFormatter) - ? labelFormatter(value, valueStr) - : isString(labelFormatter) - ? labelFormatter.replace('{value}', valueStr) - : valueStr; - } - private _onOverDataInfoTriggerArea(isOver: boolean): void { this._isOverDataInfoTriggerArea = isOver; this._showDataInfo(isOver); @@ -1149,6 +1128,42 @@ class SliderZoomView extends DataZoomView { } +function formatLabel( + dataZoomModel: SliderZoomModel, + extentIdx: 0 | 1, + window: AxisProxyWindow, + scale: Scale +): string { + const labelFormatter = dataZoomModel.get('labelFormatter'); + + let labelPrecision = dataZoomModel.get('labelPrecision'); + if (labelPrecision == null || labelPrecision === 'auto') { + labelPrecision = window.valuePrecision; + } + + // Do not display values out of `SCALE_EXTENT_KIND_EFFECTIVE` - generally they are meaningless. + // For example, `scaleExtent[0]` is often `0`, and negative values are unlikely to be meaningful. + // That is, "nice" expansion and `SCALE_EXTENT_KIND_MAPPING` expansion are always not display in labels. + const value = (extentIdx ? mathMin : mathMax)( + window.value[extentIdx], + window.noZoomEffMM[extentIdx], + ); + + const valueStr = (value == null || isNaN(value)) + ? '' + : (isOrdinalScale(scale) || isTimeScale(scale)) + ? scale.getLabel({value: Math.round(value)}) + : isFinite(labelPrecision) + ? round(value, labelPrecision, true) + : value + ''; + + return isFunction(labelFormatter) + ? labelFormatter(value, valueStr) + : isString(labelFormatter) + ? labelFormatter.replace('{value}', valueStr) + : valueStr; +} + function getOtherDim(thisDim: 'x' | 'y' | 'radius' | 'angle' | 'single' | 'z') { // FIXME // 这个逻辑和getOtherAxis里一致,但是写在这里是否不好 diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 3f64dcf6b5..628ea1b1f8 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -92,10 +92,14 @@ type ScaleRawExtentResultForContainShape = Pick< >; /** - * Return the min max before `dataZoom` applied for mapping. - * "mapping" means `SCALE_EXTENT_KIND_MAPPING`. + * Return the min max before `dataZoom` applied. */ -type ScaleRawExtentResultForZoom = number[]; +export type ScaleRawExtentResultForZoom = { + // "effective" means `SCALE_EXTENT_KIND_EFFECTIVE`. + noZoomEffMM: number[]; + // "mapping" means `SCALE_EXTENT_KIND_MAPPING`. + noZoomMapMM: number[]; +}; type ScaleRawExtentResultFinal = Pick< ScaleRawExtentInternal, @@ -343,7 +347,10 @@ export class ScaleRawExtentInfo { makeForZoom(): ScaleRawExtentResultForZoom { const internal = this._i; - return (internal.noZoomEffMMExp || internal.noZoomEffMM).slice(); + return { + noZoomEffMM: internal.noZoomEffMM.slice(), + noZoomMapMM: makeNoZoomMappingMM(internal), + }; } makeFinal(): ScaleRawExtentResultFinal { @@ -359,7 +366,7 @@ export class ScaleRawExtentInfo { needCrossZero: internal.needCrossZero, needToggleAxisInverse: internal.needToggleAxisInverse, effMM: noZoomEffMM.slice(), - mapMM: this.makeForZoom(), + mapMM: makeNoZoomMappingMM(internal), }; const effMM = result.effMM; const mapMM = result.mapMM; @@ -414,6 +421,10 @@ export class ScaleRawExtentInfo { } +function makeNoZoomMappingMM(internal: ScaleRawExtentInternal): number[] { + return (internal.noZoomEffMMExp || internal.noZoomEffMM).slice(); +} + /** * Should be called when a new extent is created or modified. */ diff --git a/test/bar-overflow-plot2.html b/test/bar-overflow-plot2.html index cec30e22f8..c297ef4109 100644 --- a/test/bar-overflow-plot2.html +++ b/test/bar-overflow-plot2.html @@ -165,6 +165,7 @@ 'Bars must not overflow the xAxis.', 'Check yAxis onZero to xAxis (xAxis is value axis).', `series data min value is **${testHelper.printObject(_data.minXSet)}**`, + `dataZoom min label should **not below zero**`, ], option: createOption(), inputsStyle: 'compact', From b16d96c39fb3fd366f31f3ab510acd183891bd64 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 20 Feb 2026 15:43:27 +0800 Subject: [PATCH 18/31] chore: Tweak the usage of isFinite. --- src/component/axisPointer/axisTrigger.ts | 3 ++- src/coord/axisAlignTicks.ts | 3 ++- src/data/helper/sourceHelper.ts | 3 ++- src/layout/barGrid.ts | 6 +++--- src/scale/breakImpl.ts | 2 +- src/scale/scaleMapper.ts | 8 ++++---- src/util/format.ts | 4 ++-- src/util/number.ts | 10 ++++++++++ 8 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/component/axisPointer/axisTrigger.ts b/src/component/axisPointer/axisTrigger.ts index 082b61c0d9..326786d1a6 100644 --- a/src/component/axisPointer/axisTrigger.ts +++ b/src/component/axisPointer/axisTrigger.ts @@ -26,6 +26,7 @@ import { Dictionary, Payload, CommonAxisPointerOption, HighlightPayload, Downpla import AxisPointerModel, { AxisPointerOption } from './AxisPointerModel'; import { each, curry, bind, extend, Curry1 } from 'zrender/src/core/util'; import { ZRenderType } from 'zrender/src/zrender'; +import { isNullableNumberFinite } from '../../util/number'; const inner = makeInner<{ axisPointerLastHighlights: Dictionary @@ -288,7 +289,7 @@ function buildPayloadsBySeries(value: AxisValue, axisInfo: CollectedAxisInfo) { seriesNestestValue = series.getData().get(dataDim[0], dataIndices[0]); } - if (seriesNestestValue == null || !isFinite(seriesNestestValue)) { + if (!isNullableNumberFinite(seriesNestestValue)) { return; } diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index b400e4db87..0fb54eedd4 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -19,6 +19,7 @@ import { getAcceptableTickPrecision, + isNullableNumberFinite, mathAbs, mathCeil, mathFloor, mathMax, mathRound, nice, NICE_MODE_MIN, quantity, round } from '../util/number'; import IntervalScale from '../scale/Interval'; @@ -243,7 +244,7 @@ export function scaleCalcAlign( intervalPrecision = getAcceptableTickPrecision([max, min], pxSpan, 0.5 / alignToNiceSegCount); updateMinNiceFromMinT0Interval(); updateMaxNiceFromMaxT1Interval(); - if (isFinite(intervalPrecision)) { + if (isNullableNumberFinite(intervalPrecision)) { interval = round(interval, intervalPrecision); } } diff --git a/src/data/helper/sourceHelper.ts b/src/data/helper/sourceHelper.ts index a4c47721a9..67668666a5 100644 --- a/src/data/helper/sourceHelper.ts +++ b/src/data/helper/sourceHelper.ts @@ -450,7 +450,8 @@ function doGuessOrdinal( const beStr = isString(val); // Consider usage convenience, '1', '2' will be treated as "number". // `Number('')` (or any whitespace) is `0`. - if (val != null && Number.isFinite(Number(val)) && val !== '') { + // `Number(val)` prevents error for BigInt. + if (val != null && isFinite(Number(val)) && val !== '') { return beStr ? BE_ORDINAL.Might : BE_ORDINAL.Not; } else if (beStr && val !== '-') { diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index dcba7aa30e..88c0336001 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -18,7 +18,7 @@ */ import { each, defaults, hasOwn, assert } from 'zrender/src/core/util'; -import { mathAbs, mathMax, mathMin, parsePercent } from '../util/number'; +import { isNullableNumberFinite, mathAbs, mathMax, mathMin, parsePercent } from '../util/number'; import { isDimensionStacked } from '../data/helper/dataStackHelper'; import createRenderPlanner from '../chart/helper/createRenderPlanner'; import Axis2D from '../coord/cartesian/Axis2D'; @@ -340,7 +340,7 @@ function createLayoutInfoListOnAxis( const linearScaleSpan = getScaleLinearSpanForMapping(axisScale); // `linearScaleSpan` may be `0` or `Infinity` or `NaN`, since normalizers like // `intervalScaleEnsureValidExtent` may not have been called yet. - if (axisPre.linearMinGap && linearScaleSpan && isFinite(linearScaleSpan)) { + if (axisPre.linearMinGap && linearScaleSpan && isNullableNumberFinite(linearScaleSpan)) { singular = false; bandWidth = pxSpan / linearScaleSpan * axisPre.linearMinGap; pxToDataRatio = linearScaleSpan / pxSpan; @@ -759,7 +759,7 @@ function calcShapeOverflowSupplement( const linearSpan = getScaleLinearSpanForMapping(scale); linearSupplement = [-linearSpan * SINGULAR_SUPPLEMENT_RATIO, linearSpan * SINGULAR_SUPPLEMENT_RATIO]; } - else if (pxToDataRatio != null && isFinite(pxToDataRatio)) { + else if (isNullableNumberFinite(pxToDataRatio)) { // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on // `minGap` and extent on data domain. linearSupplement = [barsBoundPx[0] * pxToDataRatio, barsBoundPx[1] * pxToDataRatio]; diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index 6fe9c26209..f0f2e5ba21 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -436,7 +436,7 @@ function addBreaksToTicks( // The input ticks should be in accending order. ticks: ScaleTick[], breaks: ParsedAxisBreakList, - scaleExtent: [number, number], + scaleExtent: number[], // Keep the break ends at the same level to avoid an awkward appearance. getTimeProps?: (clampedBrk: ParsedAxisBreak) => ScaleTick['time'], ): void { diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts index 5dd02a687f..57b06e1611 100644 --- a/src/scale/scaleMapper.ts +++ b/src/scale/scaleMapper.ts @@ -204,7 +204,7 @@ export interface ScaleMapperGeneric { * An extent is always in an increase order. * It always returns an array - never be a null/undefined. */ - getExtent(this: This): [number, number]; + getExtent(this: This): number[]; /** * [NOTICE]: @@ -215,7 +215,7 @@ export interface ScaleMapperGeneric { kind: ScaleExtentKind, // NullUndefined means the outermost space. depth: ScaleMapperDepthOpt['depth'] | NullUndefined - ): [number, number] | NullUndefined; + ): number[] | NullUndefined; /** * [NOTICE]: @@ -413,11 +413,11 @@ const linearScaleMapperMethods: ScaleMapperGeneric = { }, getExtent() { - return this._extents[SCALE_EXTENT_KIND_EFFECTIVE].slice() as [number, number]; + return this._extents[SCALE_EXTENT_KIND_EFFECTIVE].slice(); }, getExtentUnsafe(kind) { - return this._extents[kind] as [number, number]; + return this._extents[kind]; }, setExtent(start, end) { diff --git a/src/util/format.ts b/src/util/format.ts index afe9625b42..6378072327 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -19,7 +19,7 @@ import * as zrUtil from 'zrender/src/core/util'; import { encodeHTML } from 'zrender/src/core/dom'; -import { parseDate, isNumeric, numericToNumber } from './number'; +import { parseDate, isNumeric, numericToNumber, isNullableNumberFinite } from './number'; import { TooltipRenderMode, ColorString, ZRColor, DimensionType } from './types'; import { Dictionary } from 'zrender/src/core/types'; import { GradientObject } from 'zrender/src/graphic/Gradient'; @@ -72,7 +72,7 @@ export function makeValueReadable( return (str && zrUtil.trim(str)) ? str : '-'; } function isNumberUserReadable(num: number): boolean { - return !!(num != null && !isNaN(num) && isFinite(num)); + return isNullableNumberFinite(num); } const isTypeTime = valueType === 'time'; diff --git a/src/util/number.ts b/src/util/number.ts index 84a802dd1b..accab98a08 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -802,3 +802,13 @@ export function getLeastCommonMultiple(a: number, b: number) { } return a * b / getGreatestCommonDividor(a, b); } + +/** + * NOTICE: Assume the input `val` is number or null/undefined, no type check. + * Therefore, it is NOT suitable for processing user input, but sufficient for + * internal usage in most cases. + * For platform-agnosticism, `Number.isFinite` is not used. + */ +export function isNullableNumberFinite(val: number | NullUndefined) { + return val != null && isFinite(val); +} From 8de2b64faa9d6f829ac2293d27920da76f47d5f5 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 27 Feb 2026 15:19:46 +0800 Subject: [PATCH 19/31] feature&fix(axis): (1) feature: Enable uniform bandWidth calculation in numeric axis (e.g., for tooltip shadow); it previously only applicable to category axis, but buggy in numeric axis with bar series. And enable the clip of tooltip shadow. (2) refactor: Introduce a dedicated workflow phase for series aggregation and data statistics computation on a single axis, allowting the results to be reused across multiple features. (3) fix: Fix duplicate ticks in TimeScale and customValues, which cause jitter of splitArea. (4) fix: Fix category showMin/MaxLabel handling when step > 1 and showMin/MaxLabel: false (5) chore: Tweak bad effects introduced by the previous implementation of SCALE_EXTENT_KIND_MAPPING. (6) chore: Clean some code. --- src/chart/bar/install.ts | 4 +- src/chart/bar/installPictorialBar.ts | 4 +- src/chart/line/LineView.ts | 2 +- src/chart/sankey/sankeyLayout.ts | 5 +- src/component/axis/AngleAxisView.ts | 6 +- src/component/axis/AxisBuilder.ts | 72 ++-- src/component/axis/axisSplitHelper.ts | 7 +- .../axisPointer/CartesianAxisPointer.ts | 18 +- src/component/axisPointer/axisTrigger.ts | 2 +- src/component/brush/preprocessor.ts | 15 +- src/component/helper/RoamController.ts | 2 +- src/component/matrix/MatrixView.ts | 2 +- src/component/timeline/SliderTimelineView.ts | 5 +- src/coord/Axis.ts | 24 +- src/coord/axisBand.ts | 159 +++++++++ src/coord/axisCommonTypes.ts | 15 +- src/coord/axisDefault.ts | 6 +- src/coord/axisHelper.ts | 1 + src/coord/axisNiceTicks.ts | 5 +- src/coord/axisStatistics.ts | 262 ++++++++++++++ src/coord/axisTickLabelBuilder.ts | 104 +++--- src/coord/cartesian/Grid.ts | 2 +- src/coord/matrix/Matrix.ts | 2 +- src/coord/scaleRawExtentInfo.ts | 2 +- src/core/CoordinateSystem.ts | 8 + src/core/echarts.ts | 6 +- src/data/DataStore.ts | 69 ++-- src/data/SeriesData.ts | 5 +- src/data/helper/createDimensions.ts | 30 +- src/data/helper/dataValueHelper.ts | 73 +++- src/layout/barGrid.ts | 332 +++++------------- src/scale/Log.ts | 6 +- src/scale/Ordinal.ts | 4 + src/scale/Time.ts | 59 ++-- src/scale/breakImpl.ts | 4 + src/scale/scaleMapper.ts | 26 +- src/util/model.ts | 69 +++- src/util/number.ts | 2 +- src/util/types.ts | 10 +- test/bar-overflow-time-plot.html | 3 + test/ut/spec/util/model.test.ts | 163 ++++++++- 41 files changed, 1073 insertions(+), 522 deletions(-) create mode 100644 src/coord/axisBand.ts create mode 100644 src/coord/axisStatistics.ts diff --git a/src/chart/bar/install.ts b/src/chart/bar/install.ts index eefe3bca84..65ab609637 100644 --- a/src/chart/bar/install.ts +++ b/src/chart/bar/install.ts @@ -19,7 +19,7 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import * as zrUtil from 'zrender/src/core/util'; -import {layout, createProgressiveLayout, registerBarGridAxisContainShapeHandler} from '../../layout/barGrid'; +import {layout, createProgressiveLayout, registerBarGridAxisHandlers} from '../../layout/barGrid'; import dataSample from '../../processor/dataSample'; import BarSeries from './BarSeries'; @@ -67,5 +67,5 @@ export function install(registers: EChartsExtensionInstallRegisters) { ); }); - registerBarGridAxisContainShapeHandler(registers); + registerBarGridAxisHandlers(registers); } diff --git a/src/chart/bar/installPictorialBar.ts b/src/chart/bar/installPictorialBar.ts index 5c529444ce..dc5ba41b65 100644 --- a/src/chart/bar/installPictorialBar.ts +++ b/src/chart/bar/installPictorialBar.ts @@ -20,7 +20,7 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import PictorialBarView from './PictorialBarView'; import PictorialBarSeriesModel from './PictorialBarSeries'; -import { createProgressiveLayout, layout, registerBarGridAxisContainShapeHandler } from '../../layout/barGrid'; +import { createProgressiveLayout, layout, registerBarGridAxisHandlers } from '../../layout/barGrid'; import { curry } from 'zrender/src/core/util'; export function install(registers: EChartsExtensionInstallRegisters) { @@ -31,5 +31,5 @@ export function install(registers: EChartsExtensionInstallRegisters) { // Do layout after other overall layout, which can prepare some information. registers.registerLayout(registers.PRIORITY.VISUAL.PROGRESSIVE_LAYOUT, createProgressiveLayout('pictorialBar')); - registerBarGridAxisContainShapeHandler(registers); + registerBarGridAxisHandlers(registers); } diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 3d59a77525..fd7934e52a 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -399,7 +399,7 @@ function getIsIgnoreFunc( zrUtil.each(categoryAxis.getViewLabels(), function (labelItem) { const ordinalNumber = (categoryAxis.scale as OrdinalScale) - .getRawOrdinalNumber(labelItem.tickValue); + .getRawOrdinalNumber(labelItem.tick.value); labelMap[ordinalNumber] = 1; }); diff --git a/src/chart/sankey/sankeyLayout.ts b/src/chart/sankey/sankeyLayout.ts index 8e9c8d22bb..4b405925b1 100644 --- a/src/chart/sankey/sankeyLayout.ts +++ b/src/chart/sankey/sankeyLayout.ts @@ -25,6 +25,7 @@ import { GraphNode, GraphEdge } from '../../data/Graph'; import { LayoutOrient } from '../../util/types'; import GlobalModel from '../../model/Global'; import { createBoxLayoutReference, getLayoutRect } from '../../util/layout'; +import { asc } from '../../util/number'; export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { @@ -290,9 +291,7 @@ function prepareNodesByBreadth(nodes: GraphNode[], orient: LayoutOrient) { const groupResult = groupData(nodes, function (node) { return node.getLayout()[keyAttr] as number; }); - groupResult.keys.sort(function (a, b) { - return a - b; - }); + asc(groupResult.keys); zrUtil.each(groupResult.keys, function (key) { nodesByBreadth.push(groupResult.buckets.get(key)); }); diff --git a/src/component/axis/AngleAxisView.ts b/src/component/axis/AngleAxisView.ts index a694c4e491..95dbb59003 100644 --- a/src/component/axis/AngleAxisView.ts +++ b/src/component/axis/AngleAxisView.ts @@ -101,8 +101,8 @@ class AngleAxisView extends AxisView { labelItem = zrUtil.clone(labelItem); const scale = angleAxis.scale; const tickValue = scale.type === 'ordinal' - ? (scale as OrdinalScale).getRawOrdinalNumber(labelItem.tickValue) - : labelItem.tickValue; + ? (scale as OrdinalScale).getRawOrdinalNumber(labelItem.tick.value) + : labelItem.tick.value; labelItem.coord = angleAxis.dataToCoord(tickValue); return labelItem; }); @@ -250,7 +250,7 @@ const angelAxisElementsBuilders: Record(); const getTickInner = makeInner<{ - onBand: AxisTickCoord['onBand'] - tickValue: AxisTickCoord['tickValue'] + onBand: AxisTickCoord['onBand']; + tickValue: AxisTickCoord['tickValue']; }, graphic.Line>(); @@ -1061,7 +1063,10 @@ function fixMinMaxLabelShow( labelLayoutList: LabelLayoutData[], optionHideOverlap: AxisBaseOption['axisLabel']['hideOverlap'] ) { - if (shouldShowAllLabels(axisModel.axis)) { + const axis = axisModel.axis; + const customValuesOption = axisModel.get(['axisLabel', 'customValues']); + + if (shouldShowAllLabels(axis)) { return; } @@ -1070,7 +1075,7 @@ function fixMinMaxLabelShow( // Assert no ignore in labels. function deal( - showMinMaxLabel: boolean, + showMinMaxLabelOption: AxisShowMinMaxLabelOption, outmostLabelIdx: number, innerLabelIdx: number, ) { @@ -1079,8 +1084,18 @@ function fixMinMaxLabelShow( if (!outmostLabelLayout || !innerLabelLayout) { return; } + if (showMinMaxLabelOption == null) { + if (!optionHideOverlap && customValuesOption) { + // In this case, users are unlikely to expect labels to be hidden. + return; + } + if (isTimeScale(axis.scale) && getLabelInner(outmostLabelLayout.label).labelInfo.tick.notNice) { + // TimeScale does not expand extent to "nice", so eliminate labels that are not nice. + ignoreEl(outmostLabelLayout.label); + } + } - if (showMinMaxLabel === false || outmostLabelLayout.suggestIgnore) { + if (showMinMaxLabelOption === false || outmostLabelLayout.suggestIgnore) { ignoreEl(outmostLabelLayout.label); return; } @@ -1107,7 +1122,7 @@ function fixMinMaxLabelShow( innerLabelLayout = newLabelLayoutWithGeometry({marginForce}, innerLabelLayout); } if (labelIntersect(outmostLabelLayout, innerLabelLayout, null, {touchThreshold})) { - if (showMinMaxLabel) { + if (showMinMaxLabelOption) { ignoreEl(innerLabelLayout.label); } else { @@ -1119,11 +1134,11 @@ function fixMinMaxLabelShow( // If min or max are user set, we need to check // If the tick on min(max) are overlap on their neighbour tick // If they are overlapped, we need to hide the min(max) tick label - const showMinLabel = axisModel.get(['axisLabel', 'showMinLabel']); - const showMaxLabel = axisModel.get(['axisLabel', 'showMaxLabel']); + const showMinLabelOption = axisModel.get(['axisLabel', 'showMinLabel']); + const showMaxLabelOption = axisModel.get(['axisLabel', 'showMaxLabel']); const labelsLen = labelLayoutList.length; - deal(showMinLabel, 0, 1); - deal(showMaxLabel, labelsLen - 1, labelsLen - 2); + deal(showMinLabelOption, 0, 1); + deal(showMaxLabelOption, labelsLen - 1, labelsLen - 2); } // PENDING: Is it necessary to display a tick while the corresponding label is ignored? @@ -1146,7 +1161,7 @@ function syncLabelIgnoreToMajorTicks( const labelInner = getLabelInner(labelLayout.label); if (tickInner.tickValue != null && !tickInner.onBand - && tickInner.tickValue === labelInner.tickValue + && tickInner.tickValue === labelInner.labelInfo.tick.value ) { ignoreEl(tickEl); return; @@ -1355,9 +1370,11 @@ function buildAxisLabel( let z2Max = -Infinity; each(labels, function (labelItem, index) { + const labelItemTick = labelItem.tick; + const labelItemTickValue = labelItemTick.value; const tickValue = axis.scale.type === 'ordinal' - ? (axis.scale as OrdinalScale).getRawOrdinalNumber(labelItem.tickValue) - : labelItem.tickValue; + ? (axis.scale as OrdinalScale).getRawOrdinalNumber(labelItemTickValue) + : labelItemTickValue; const formattedLabel = labelItem.formattedLabel; const rawLabel = labelItem.rawLabel; @@ -1396,7 +1413,7 @@ function buildAxisLabel( itemLabelModel.getShallow('verticalAlignMaxLabel', true), verticalAlign ); - const z2 = 10 + (labelItem.time?.level || 0); + const z2 = 10 + (labelItemTick.time?.level || 0); z2Min = Math.min(z2Min, z2); z2Max = Math.max(z2Max, z2); @@ -1443,8 +1460,7 @@ function buildAxisLabel( textEl.anid = 'label_' + tickValue; const inner = getLabelInner(textEl); - inner.break = labelItem.break; - inner.tickValue = tickValue; + inner.labelInfo = labelItem; inner.layoutRotation = labelLayout.rotation; graphic.setTooltipConfig({ @@ -1464,11 +1480,13 @@ function buildAxisLabel( eventData.targetType = 'axisLabel'; eventData.value = rawLabel; eventData.tickIndex = index; - if (labelItem.break) { + const labelItemTickBreak = labelItem.tick.break; + const labelItemTickBreakParsedBreak = labelItemTickBreak.parsedBreak; + if (labelItemTickBreak) { eventData.break = { // type: labelItem.break.type, - start: labelItem.break.parsedBreak.vmin, - end: labelItem.break.parsedBreak.vmax, + start: labelItemTickBreakParsedBreak.vmin, + end: labelItemTickBreakParsedBreak.vmax, }; } if (axis.type === 'category') { @@ -1477,8 +1495,8 @@ function buildAxisLabel( getECData(textEl).eventData = eventData; - if (labelItem.break) { - addBreakEventHandler(axisModel, api, textEl, labelItem.break); + if (labelItemTickBreak) { + addBreakEventHandler(axisModel, api, textEl, labelItemTickBreak); } } @@ -1488,7 +1506,7 @@ function buildAxisLabel( const labelLayoutList = map(labelEls, label => ({ label, - priority: getLabelInner(label).break + priority: getLabelInner(label).labelInfo.tick.break ? label.z2 + (z2Max - z2Min + 1) // Make break labels be highest priority. : label.z2, defaultAttr: { @@ -1537,7 +1555,7 @@ function updateAxisLabelChangableProps( labelEl.ignore = false; copyTransform(_tmpLayoutEl, _tmpLayoutElReset); - _tmpLayoutEl.x = axisModel.axis.dataToCoord(inner.tickValue); + _tmpLayoutEl.x = axisModel.axis.dataToCoord(inner.labelInfo.tick.value); _tmpLayoutEl.y = cfg.labelOffset + cfg.labelDirection * labelMargin; _tmpLayoutEl.rotation = inner.layoutRotation; @@ -1590,7 +1608,7 @@ function adjustBreakLabels( } const breakLabelIndexPairs = scaleBreakHelper.retrieveAxisBreakPairs( labelLayoutList, - layoutInfo => layoutInfo && getLabelInner(layoutInfo.label).break, + layoutInfo => layoutInfo && getLabelInner(layoutInfo.label).labelInfo.tick.break, true ); const moveOverlap = axisModel.get(['breakLabelLayout', 'moveOverlap'], true); diff --git a/src/component/axis/axisSplitHelper.ts b/src/component/axis/axisSplitHelper.ts index 7679bbd1e7..9e4e610da1 100644 --- a/src/component/axis/axisSplitHelper.ts +++ b/src/component/axis/axisSplitHelper.ts @@ -26,6 +26,7 @@ import type CartesianAxisView from './CartesianAxisView'; import type SingleAxisModel from '../../coord/single/AxisModel'; import type CartesianAxisModel from '../../coord/cartesian/AxisModel'; import AxisView from './AxisView'; +import type { AxisBaseModel } from '../../coord/AxisBaseModel'; const inner = makeInner<{ // Hash map of color index @@ -35,7 +36,7 @@ const inner = makeInner<{ export function rectCoordAxisBuildSplitArea( axisView: SingleAxisView | CartesianAxisView, axisGroup: graphic.Group, - axisModel: SingleAxisModel | CartesianAxisModel, + axisModel: (SingleAxisModel | CartesianAxisModel) & AxisBaseModel, gridModel: GridModel | SingleAxisModel ) { const axis = axisModel.axis; @@ -44,8 +45,7 @@ export function rectCoordAxisBuildSplitArea( return; } - // TODO: TYPE - const splitAreaModel = (axisModel as CartesianAxisModel).getModel('splitArea'); + const splitAreaModel = axisModel.getModel('splitArea'); const areaStyleModel = splitAreaModel.getModel('areaStyle'); let areaColors = areaStyleModel.get('color'); @@ -107,7 +107,6 @@ export function rectCoordAxisBuildSplitArea( const tickValue = ticksCoords[i - 1].tickValue; tickValue != null && newSplitAreaColors.set(tickValue, colorIndex); - axisGroup.add(new graphic.Rect({ anid: tickValue != null ? 'area_' + tickValue : null, shape: { diff --git a/src/component/axisPointer/CartesianAxisPointer.ts b/src/component/axisPointer/CartesianAxisPointer.ts index 6b8b344232..325768563f 100644 --- a/src/component/axisPointer/CartesianAxisPointer.ts +++ b/src/component/axisPointer/CartesianAxisPointer.ts @@ -27,6 +27,7 @@ import Grid from '../../coord/cartesian/Grid'; import Axis2D from '../../coord/cartesian/Axis2D'; import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; +import { isNullableNumberFinite, mathMax, mathMin } from '../../util/number'; // Not use top level axisPointer model type AxisPointerModel = Model; @@ -105,8 +106,8 @@ class CartesianAxisPointer extends BaseAxisPointer { const currPosition = [transform.x, transform.y]; currPosition[dimIndex] += delta[dimIndex]; - currPosition[dimIndex] = Math.min(axisExtent[1], currPosition[dimIndex]); - currPosition[dimIndex] = Math.max(axisExtent[0], currPosition[dimIndex]); + currPosition[dimIndex] = mathMin(axisExtent[1], currPosition[dimIndex]); + currPosition[dimIndex] = mathMax(axisExtent[0], currPosition[dimIndex]); const cursorOtherValue = (otherExtent[1] + otherExtent[0]) / 2; const cursorPoint = [cursorOtherValue, cursorOtherValue]; @@ -156,13 +157,18 @@ const pointerShapeBuilder = { }, shadow: function (axis: Axis2D, pixelValue: number, otherExtent: number[]): PathProps & { type: 'Rect'} { - const bandWidth = Math.max(1, axis.getBandWidth()); - const span = otherExtent[1] - otherExtent[0]; + let bandWidth = axis.getBandWidth(); + const thisExtent = axis.getGlobalExtent(); + bandWidth = isNullableNumberFinite(bandWidth) + ? mathMax(1, bandWidth) : 1; + const otherSpan = otherExtent[1] - otherExtent[0]; + const thisX = mathMax(thisExtent[0], pixelValue - bandWidth / 2); + const thisW = mathMin(thisX + bandWidth, thisExtent[1]) - thisX; return { type: 'Rect', shape: viewHelper.makeRectShape( - [pixelValue - bandWidth / 2, otherExtent[0]], - [bandWidth, span], + [thisX, otherExtent[0]], + [thisW, otherSpan], getAxisDimIndex(axis) ) }; diff --git a/src/component/axisPointer/axisTrigger.ts b/src/component/axisPointer/axisTrigger.ts index 326786d1a6..8cc2457a14 100644 --- a/src/component/axisPointer/axisTrigger.ts +++ b/src/component/axisPointer/axisTrigger.ts @@ -368,7 +368,7 @@ function showTooltip( axisType: axisModel.type, axisId: axisModel.id, value: value as number, - // Caustion: viewHelper.getValueLabel is actually on "view stage", which + // Caution: viewHelper.getValueLabel is actually on "view stage", which // depends that all models have been updated. So it should not be performed // here. Considering axisPointerModel used here is volatile, which is hard // to be retrieve in TooltipView, we prepare parameters here. diff --git a/src/component/brush/preprocessor.ts b/src/component/brush/preprocessor.ts index 5bbbfc8cc2..4c55cf319f 100644 --- a/src/component/brush/preprocessor.ts +++ b/src/component/brush/preprocessor.ts @@ -23,7 +23,7 @@ import { ECUnitOption, Dictionary } from '../../util/types'; import { BrushOption, BrushToolboxIconType } from './BrushModel'; import { ToolboxOption } from '../toolbox/ToolboxModel'; import { ToolboxBrushFeatureOption } from '../toolbox/feature/Brush'; -import { normalizeToArray } from '../../util/model'; +import { normalizeToArray, removeDuplicates } from '../../util/model'; const DEFAULT_TOOLBOX_BTNS: BrushToolboxIconType[] = ['rect', 'polygon', 'keep', 'clear']; @@ -61,20 +61,9 @@ export default function brushPreprocessor(option: ECUnitOption, isNew: boolean): brushTypes.push.apply(brushTypes, brushComponentSpecifiedBtns); - removeDuplicate(brushTypes); + removeDuplicates(brushTypes, item => item + '', null); if (isNew && !brushTypes.length) { brushTypes.push.apply(brushTypes, DEFAULT_TOOLBOX_BTNS); } } - -function removeDuplicate(arr: string[]): void { - const map = {} as Dictionary; - zrUtil.each(arr, function (val) { - map[val] = 1; - }); - arr.length = 0; - zrUtil.each(map, function (flag, val) { - arr.push(val); - }); -} diff --git a/src/component/helper/RoamController.ts b/src/component/helper/RoamController.ts index 4c057412e5..2a87b993c6 100644 --- a/src/component/helper/RoamController.ts +++ b/src/component/helper/RoamController.ts @@ -180,7 +180,7 @@ class RoamController extends Eventful { controlType = true; } - // A handy optimization for repeatedly calling `enable` during roaming. + // A quick optimization for repeatedly calling `enable` during roaming. // Assert `disable` is only affected by `controlType`. if (!this._enabled || this._controlType !== controlType) { this._enabled = true; diff --git a/src/component/matrix/MatrixView.ts b/src/component/matrix/MatrixView.ts index ae5ff13cec..ce2e45adea 100644 --- a/src/component/matrix/MatrixView.ts +++ b/src/component/matrix/MatrixView.ts @@ -273,7 +273,7 @@ function createMatrixCell( tooltipOption: MatrixOption['tooltip'], targetType: MatrixTargetType ): void { - // Do not use getModel for handy performance optimization. + // Do not use getModel - a quick performance optimization. _tmpCellItemStyleModel.option = cellOption ? cellOption.itemStyle : null; _tmpCellItemStyleModel.parentModel = parentItemStyleModel; _tmpCellModel.option = cellOption; diff --git a/src/component/timeline/SliderTimelineView.ts b/src/component/timeline/SliderTimelineView.ts index 8bd27e6139..371e5456e8 100644 --- a/src/component/timeline/SliderTimelineView.ts +++ b/src/component/timeline/SliderTimelineView.ts @@ -43,7 +43,6 @@ import { enableHoverEmphasis } from '../../util/states'; import { createTooltipMarkup } from '../tooltip/tooltipMarkup'; import Displayable from 'zrender/src/graphic/Displayable'; import { createScaleByModel } from '../../coord/axisHelper'; -import { OptionAxisType } from '../../coord/axisCommonTypes'; import { scaleCalcNiceDirectly } from '../../coord/axisNiceTicks'; const PI = Math.PI; @@ -473,14 +472,14 @@ class SliderTimelineView extends TimelineView { each(labels, (labelItem) => { // The tickValue is dataIndex, see the customized scale. - const dataIndex = labelItem.tickValue; + const dataIndex = labelItem.tick.value; const itemModel = data.getItemModel(dataIndex); const normalLabelModel = itemModel.getModel('label'); const hoverLabelModel = itemModel.getModel(['emphasis', 'label']); const progressLabelModel = itemModel.getModel(['progress', 'label']); - const tickCoord = axis.dataToCoord(labelItem.tickValue); + const tickCoord = axis.dataToCoord(dataIndex); const textEl = new graphic.Text({ x: tickCoord, y: 0, diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index d360188344..b0370028fd 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -28,12 +28,13 @@ import { createAxisLabelsComputingContext, } from './axisTickLabelBuilder'; import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; -import { DimensionName, ScaleDataValue, ScaleTick } from '../util/types'; +import { DimensionName, NullUndefined, ScaleDataValue, ScaleTick } from '../util/types'; import OrdinalScale from '../scale/Ordinal'; import Model from '../model/Model'; import { AxisBaseOption, CategoryAxisBaseOption, OptionAxisType } from './axisCommonTypes'; import { AxisBaseModel } from './AxisBaseModel'; import { isOrdinalScale } from '../scale/helper'; +import { AxisBandWidthResult, calcBandWidth } from './axisBand'; const NORMALIZED_EXTENT = [0, 1] as [number, number]; @@ -83,7 +84,7 @@ class Axis { inverse: AxisBaseOption['inverse'] = false; // To be injected outside. May change - do not use it outside of echarts. - __alignTo: Axis; + __alignTo: Axis | NullUndefined; constructor(dim: DimensionName, scale: Scale, extent: [number, number]) { @@ -241,19 +242,12 @@ class Axis { } /** - * Get width of band + * NOTICE: Can only be called after `adoptBandWidth` being called in `CoordinateSystem#update` stage. */ getBandWidth(): number { - const axisExtent = this._extent; - const dataExtent = this.scale.getExtent(); - - let len = dataExtent[1] - dataExtent[0] + (this.onBand ? 1 : 0); - // Fix #2728, avoid NaN when only one data. - len === 0 && (len = 1); - - const size = Math.abs(axisExtent[1] - axisExtent[0]); - - return Math.abs(size) / len; + calcBandWidth(tmpOutBandWidth, this); + // NOTICE: Do not add logic here. Implement everthing in `calcBandWidth`. + return tmpOutBandWidth.bandWidth; } /** @@ -264,7 +258,7 @@ class Axis { /** * Only be called in category axis. * Can be overridden, consider other axes like in 3D. - * @return Auto interval for cateogry axis tick and label + * @return Auto interval for category axis tick and label */ calculateCategoryInterval(ctx?: AxisLabelsComputingContext): number { ctx = ctx || createAxisLabelsComputingContext(AxisTickLabelComputingKind.determine); @@ -273,6 +267,8 @@ class Axis { } +const tmpOutBandWidth: AxisBandWidthResult = {}; + function makeExtentWithBands(axis: Axis): number[] { const extent = axis.getExtent(); if (axis.onBand) { diff --git a/src/coord/axisBand.ts b/src/coord/axisBand.ts new file mode 100644 index 0000000000..95edb208f1 --- /dev/null +++ b/src/coord/axisBand.ts @@ -0,0 +1,159 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { each } from 'zrender/src/core/util'; +import { NullUndefined } from '../util/types'; +import type Axis from './Axis'; +import type Scale from '../scale/Scale'; +import { isOrdinalScale } from '../scale/helper'; +import { isNullableNumberFinite, mathAbs } from '../util/number'; +import { getAxisStatistics, getAxisStatisticsKeys } from './axisStatistics'; +import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; + + +// Arbitrary, leave some space to avoid overflowing when dataZoom moving. +const SINGULAR_BAND_WIDTH_RATIO = 0.7; + +export type AxisBandWidthResult = { + // In px. May be NaN/null/undefined if no meaningfull bandWidth. + bandWidth?: number | NullUndefined; + kind?: AxisBandWidthKind; + // If `AXIS_BAND_WIDTH_KIND_NORMAL`, this is a ratio from px span to data span, exists only if not singular. + // If `AXIS_BAND_WIDTH_KIND_SINGULAR`, no need any ratio. + ratio?: number | NullUndefined; +}; + +export type AxisBandWidthKind = + // NullUndefined means no bandWidth, typically due to no series data. + NullUndefined + | typeof AXIS_BAND_WIDTH_KIND_SINGULAR + | typeof AXIS_BAND_WIDTH_KIND_NORMAL; +export const AXIS_BAND_WIDTH_KIND_SINGULAR = 1; +export const AXIS_BAND_WIDTH_KIND_NORMAL = 2; + +/** + * NOTICE: + * Require the axis pixel extent and the scale extent as inputs. But they + * can be not precise for approximation. + * + * PENDING: + * Currently `bandWidth` can not be specified by users explicitly. But if we + * allow that in future, these issues must be considered: + * - Can only allow specifying a band width in data scale rather than pixel. + * - LogScale needs to be considered - band width can only be specified on linear + * (but before break) scale, similar to `axis.interval`. + * + * A band is required on: + * - bar series group band width; + * - tooltip axisPointer type "shadow"; + * - etc. + */ +export function calcBandWidth( + out: AxisBandWidthResult, + axis: Axis +): void { + // Clear out. + out.bandWidth = out.ratio = out.kind = undefined; + + const scale = axis.scale; + + if (isOrdinalScale(scale) + || !calcBandWidthForNumericAxisIfPossible(out, axis, scale) + ) { + calcBandWidthForCategoryAxisOrFallback(out, axis, scale); + } +} + +/** + * Only reasonable on 'category'. + * + * It can be used as a fallback, as it does not produce a significant negative impact + * on non-category axes. + */ +function calcBandWidthForCategoryAxisOrFallback( + out: AxisBandWidthResult, + axis: Axis, + scale: Scale +): void { + const axisExtent = axis.getExtent(); + const dataExtent = scale.getExtent(); + + let len = dataExtent[1] - dataExtent[0] + (axis.onBand ? 1 : 0); + // Fix #2728, avoid NaN when only one data. + len === 0 && (len = 1); + + const size = Math.abs(axisExtent[1] - axisExtent[0]); + + out.bandWidth = Math.abs(size) / len; +} + +function calcBandWidthForNumericAxisIfPossible( + out: AxisBandWidthResult, + axis: Axis, + scale: Scale, + // A falsy return indicates this method is not applicable - a fallback is needed. +): boolean { + // PENDING: Theoretically, for 'value'/'time'/'log' axis, `bandWidth` should be derived from + // series data and may vary per data items. However, we currently only derive `bandWidth` + // per serise, regardless of individual data items, until concrete requirements arise. + // Therefore, we arbitrarily choose a minimal `bandWidth` to avoid overlap if multiple + // irrelevant series reside on one axis. + let hasStat: boolean; + let linearPositiveMinGap = Infinity; + each(getAxisStatisticsKeys(axis), function (axisStatKey) { + const liMinGap = getAxisStatistics(axis, axisStatKey).linearPositiveMinGap; + if (liMinGap != null) { + hasStat = true; + if (isNullableNumberFinite(liMinGap) && liMinGap < linearPositiveMinGap) { + linearPositiveMinGap = liMinGap; + } + } + }); + if (!hasStat) { + return false; + } + + let bandWidth: number | NullUndefined; + let kind: AxisBandWidthKind | NullUndefined; + let ratio: number | NullUndefined; + + const axisExtent = axis.getExtent(); + // Always use a new pxSpan because it may be changed in `grid` contain label calculation. + const pxSpan = mathAbs(axisExtent[1] - axisExtent[0]); + const linearScaleSpan = getScaleLinearSpanForMapping(scale); + // `linearScaleSpan` may be `0` or `Infinity` or `NaN`, since normalizers like + // `intervalScaleEnsureValidExtent` may not have been called yet. + if (isNullableNumberFinite(linearScaleSpan) && linearScaleSpan > 0 + && isNullableNumberFinite(linearPositiveMinGap) + ) { + bandWidth = pxSpan / linearScaleSpan * linearPositiveMinGap; + ratio = linearScaleSpan / pxSpan; + kind = AXIS_BAND_WIDTH_KIND_NORMAL; + } + else { + bandWidth = pxSpan * SINGULAR_BAND_WIDTH_RATIO; + kind = AXIS_BAND_WIDTH_KIND_SINGULAR; + } + + out.bandWidth = bandWidth; + out.kind = kind; + out.ratio = ratio; + + return true; +} diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 229290dacd..32b196d1bd 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -260,7 +260,7 @@ interface AxisTickOption { // The length of axisTick. length?: number, lineStyle?: LineStyleOption, - customValues?: (number | string | Date)[] + customValues?: AxisTickLabelCustomValuesOption } export type AxisLabelValueFormatter = ( @@ -326,10 +326,8 @@ interface AxisLabelBaseOption extends LabelCommonOption extends AxisLabelBaseOption { formatter?: LabelFormatters[TType] interval?: TType extends 'category' diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index c30d59f906..ddb33ee33f 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -212,9 +212,9 @@ const valueAxis: AxisBaseOption = zrUtil.merge({ const timeAxis: AxisBaseOption = zrUtil.merge({ splitNumber: 6, axisLabel: { - // To eliminate labels that are not nice - showMinLabel: false, - showMaxLabel: false, + // The default value of TimeScale is determined in `AxisBuilder` + // showMinLabel: false, + // showMaxLabel: false, rich: { primary: { fontWeight: 'bold' diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 36fbb39ab4..8739c5ebff 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -38,6 +38,7 @@ import { AxisLabelFormatterExtraParams, OptionAxisType, AXIS_TYPES, + AxisShowMinMaxLabelOption, } from './axisCommonTypes'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts index 856a7ff2db..186459e8a3 100644 --- a/src/coord/axisNiceTicks.ts +++ b/src/coord/axisNiceTicks.ts @@ -46,7 +46,7 @@ import type Axis from './Axis'; // ------ START: LinearIntervalScaleStub Nice ------ function calcNiceForIntervalOrLogScale( - scale: IntervalScale | LogScale, + scale: (IntervalScale | LogScale) & Scale, opt: ScaleCalcNiceMethodOpt, ): void { // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. @@ -187,6 +187,9 @@ type ScaleCalcNiceMethodOpt = { /** * NOTE: See the summary of the process of extent determination in the comment of `scaleMapper.setExtent`. + * + * Calculate a "nice" extent and "nice" ticks configs based on the current scale extent and ec options. + * scale extent will be modified, and config may be set to the scale. */ export function scaleCalcNice( axisLike: { diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts new file mode 100644 index 0000000000..c677a4337d --- /dev/null +++ b/src/coord/axisStatistics.ts @@ -0,0 +1,262 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { assert, clone, createHashMap, each, HashMap } from 'zrender/src/core/util'; +import type GlobalModel from '../model/Global'; +import type SeriesModel from '../model/Series'; +import { + extentHasValue, getCachePerECFullUpdate, GlobalModelCachePerECFullUpdate, + initExtentForUnion, makeInner, +} from '../util/model'; +import { NullUndefined } from '../util/types'; +import type Axis from './Axis'; +import { asc, isNullableNumberFinite, mathMin } from '../util/number'; +import { registerPerformAxisStatistics } from '../core/CoordinateSystem'; +import { parseSanitizationFilter, passesSanitizationFilter } from '../data/helper/dataValueHelper'; + + +const ecModelCacheInner = makeInner<{ + axes: Axis[]; +}, GlobalModelCachePerECFullUpdate>(); +type AxisStatisticsStore = { + stat: AxisStatisticsPerAxis | NullUndefined; + // For duplication checking. + added: boolean +}; +const axisInner = makeInner(); + +export type AxisStatisticsClient = { + collectAxisSeries: ( + ecModel: GlobalModel, + saveAxisSeries: (axis: Axis, series: SeriesModel) => void + ) => void; + getMetrics: ( + axis: Axis, + ) => AxisStatisticsMetrics; +}; + +/** + * Nominal to avoid misusing. + * Sample usage: + * function axisStatKey(seriesType: ComponentSubType): AxisStatisticsKey { + * return `xxx-${seriesType}` as AxisStatisticsKey; + * } + */ +export type AxisStatisticsKey = string & {_: 'AxisStatisticsKey'}; + +type AxisStatisticsMetrics = { + // Currently only one metric is required. + // NOTICE: + // May be time-consuming due to some metrics requiring travel and sort of series data, + // especially when axis break is used, so it is performed only if required. + minGap?: boolean +}; + +type AxisStatisticsPerAxis = HashMap; + +type AxisStatisticsPerAxisPerKey = { + // Mark that any statistics has been performed in this record. Also for duplication checking. + added?: boolean + // This is series use this axis as base axis and need to be laid out. + sers: SeriesModel[]; + // Minimal positive gap of values of all relevant series (e.g. per `BaseBarSeriesSubType`) on this axis. + // Be `NaN` if no valid data item or only one valid data item. + // Be `null`/`undefined` if this statistics is not performed. + linearPositiveMinGap?: number | NullUndefined; + // min/max of values of all relevant series (e.g. per `BaseBarSeriesSubType`) on this axis. + // Be `null`/`undefined` if this statistics is not performed, + // otherwise it is an array, but may contain `NaN` if no valid data. + linearValueExtent?: number[] | NullUndefined; +}; + +export type AxisStatisticsResult = Pick< + AxisStatisticsPerAxisPerKey, + 'linearPositiveMinGap' | 'linearValueExtent' +>; + +function ensureAxisStatisticsPerAxisPerKey( + axisStore: AxisStatisticsStore, axisStatKey: AxisStatisticsKey +): AxisStatisticsPerAxisPerKey { + if (__DEV__) { + assert(axisStatKey != null); + } + const stat = axisStore.stat || (axisStore.stat = createHashMap()); + return stat.get(axisStatKey) + || stat.set(axisStatKey, {sers: []}); +} + +export function getAxisStatistics( + axis: Axis, + axisStatKey: AxisStatisticsKey + // Never return null/undefined. +): AxisStatisticsResult { + const record = ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey); + return { + linearPositiveMinGap: record.linearPositiveMinGap, + linearValueExtent: clone(record.linearValueExtent), + }; +} + +export function getAxisStatisticsKeys( + axis: Axis +): AxisStatisticsKey[] { + const stat = axisInner(axis).stat; + return stat ? stat.keys() : []; +} + +export function eachCollectedSeries( + axis: Axis, + axisStatKey: AxisStatisticsKey, + cb: (series: SeriesModel) => void +): void { + each(ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey).sers, cb); +} + +export function getCollectedSeriesLength( + axis: Axis, + axisStatKey: AxisStatisticsKey, +): number { + return ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey).sers.length; +} + +export function eachCollectedAxis( + ecModel: GlobalModel, + cb: (axis: Axis) => void +): void { + each(ecModelCacheInner(getCachePerECFullUpdate(ecModel)).axes, cb); +} + +/** + * Perform statistics if required. + */ +function performAxisStatisticsImpl(ecModel: GlobalModel): void { + const ecCache = ecModelCacheInner(getCachePerECFullUpdate(ecModel)); + const distinctAxes: Axis[] = ecCache.axes = []; + + const records: AxisStatisticsPerAxisPerKey[] = []; + const recordAxes: Axis[] = []; + const recordMetricsList: AxisStatisticsMetrics[] = []; + + axisStatisticsClients.each(function (client, axisStatKey) { + client.collectAxisSeries( + ecModel, + function saveAxisSeries(axis, series): void { + const axisStore = axisInner(axis); + if (!axisStore.added) { + axisStore.added = true; + distinctAxes.push(axis); + } + const record = ensureAxisStatisticsPerAxisPerKey(axisStore, axisStatKey); + if (!record.added) { + record.added = true; + records.push(record); + recordAxes.push(axis); + recordMetricsList.push(client.getMetrics(axis) || {}); + } + // NOTICE: series order should respect to the input order, + // since it matters in some cases (see `barGrid`). + record.sers.push(series); + } + ); + }); + + each(records, function (record, idx) { + performStatisticsForRecord(record, recordMetricsList[idx], recordAxes[idx]); + }); +} + +function performStatisticsForRecord( + record: AxisStatisticsPerAxisPerKey, + metrics: AxisStatisticsMetrics, + axis: Axis, +): void { + if (!metrics.minGap) { + return; + } + + const linearValueExtent = initExtentForUnion(); + const scale = axis.scale; + const needTransform = scale.needTransform(); + const filter = scale.getFilter ? scale.getFilter() : null; + const filterParsed = parseSanitizationFilter(filter); + let valIdx = 0; + + each(record.sers, function (seriesModel) { + const data = seriesModel.getData(); + const dimIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); + const store = data.getStore(); + + for (let i = 0, cnt = store.count(); i < cnt; ++i) { + // Manually inline some code for performance, since no other optimization + // (such as, progressive) can be applied here. + let val = store.get(dimIdx, i) as number; + // NOTE: in most cases, filter does not exist. + if (isFinite(val) + && (!filter || passesSanitizationFilter(filterParsed, val)) + ) { + if (needTransform) { + // PENDING: time-consuming if axis break is applied. + val = scale.transformIn(val, null); + } + tmpStaticPSFRValues[valIdx++] = val; + val < linearValueExtent[0] && (linearValueExtent[0] = val); + val > linearValueExtent[1] && (linearValueExtent[1] = val); + } + } + }); + tmpStaticPSFRValues.length = valIdx; + + // Sort axis values into ascending order to calculate gaps + asc(tmpStaticPSFRValues); + + let min = Infinity; + for (let j = 1; j < valIdx; ++j) { + const delta = tmpStaticPSFRValues[j] - tmpStaticPSFRValues[j - 1]; + if (// - Different series normally have the same values, which should be ignored. + // - A single series with multiple same values is often not meaningful to + // create `bandWidth`, so it is also ignored. + delta > 0 + ) { + min = mathMin(min, delta); + } + } + + record.linearPositiveMinGap = isNullableNumberFinite(min) + ? min + : NaN; // No valid data item or single valid data item. + if (!extentHasValue(linearValueExtent)) { + linearValueExtent[0] = linearValueExtent[1] = NaN; // No valid data. + } + record.linearValueExtent = linearValueExtent; +} +const tmpStaticPSFRValues: number[] = []; // A quick performance optimization. + +export function requireAxisStatistics( + axisStatKey: AxisStatisticsKey, + client: AxisStatisticsClient +): void { + if (__DEV__) { + assert(!axisStatisticsClients.get(axisStatKey)); + } + + registerPerformAxisStatistics(performAxisStatisticsImpl); + axisStatisticsClients.set(axisStatKey, client); +} + +const axisStatisticsClients: HashMap = createHashMap(); diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index ec26ce6cf5..5fdd209415 100644 --- a/src/coord/axisTickLabelBuilder.ts +++ b/src/coord/axisTickLabelBuilder.ts @@ -19,28 +19,26 @@ import * as zrUtil from 'zrender/src/core/util'; import * as textContain from 'zrender/src/contain/text'; -import {makeInner} from '../util/model'; +import {makeInner, removeDuplicates, removeDuplicatesGetKeyFromItemItself} from '../util/model'; import { makeLabelFormatter, getOptionCategoryInterval, - shouldShowAllLabels } from './axisHelper'; import Axis from './Axis'; import Model from '../model/Model'; -import { AxisBaseOption, CategoryAxisBaseOption } from './axisCommonTypes'; +import { AxisBaseOption, AxisTickLabelCustomValuesOption, CategoryAxisBaseOption } from './axisCommonTypes'; import OrdinalScale from '../scale/Ordinal'; import { AxisBaseModel } from './AxisBaseModel'; import type Axis2D from './cartesian/Axis2D'; -import { NullUndefined, ScaleTick, VisualAxisBreak } from '../util/types'; -import { ScaleGetTicksOpt } from '../scale/Scale'; +import { NullUndefined, ScaleTick } from '../util/types'; +import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; +import { asc } from '../util/number'; -type AxisLabelInfoDetermined = { +export type AxisLabelInfoDetermined = { formattedLabel: string, rawLabel: string, - tickValue: number, - time: ScaleTick['time'] | NullUndefined, - break: VisualAxisBreak | NullUndefined, + tick: ScaleTick, // Never be null/undefined. }; type AxisCache = { @@ -107,37 +105,19 @@ export function createAxisLabelsComputingContext(kind: AxisTickLabelComputingKin }; } - -function tickValuesToNumbers(axis: Axis, values: (number | string | Date)[]) { - const nums = zrUtil.map(values, val => axis.scale.parse(val)); - if (axis.type === 'time' && nums.length > 0) { - // Time axis needs duplicate first/last tick (see TimeScale.getTicks()) - // The first and last tick/label don't get drawn - nums.sort(); - nums.unshift(nums[0]); - nums.push(nums[nums.length - 1]); - } - return nums; -} - export function createAxisLabels(axis: Axis, ctx: AxisLabelsComputingContext): { labels: AxisLabelInfoDetermined[] } { const custom = axis.getLabelModel().get('customValues'); if (custom) { - const labelFormatter = makeLabelFormatter(axis); - const extent = axis.scale.getExtent(); - const tickNumbers = tickValuesToNumbers(axis, custom); - const ticks = zrUtil.filter(tickNumbers, val => val >= extent[0] && val <= extent[1]); + const scale = axis.scale; return { - labels: zrUtil.map(ticks, (numval, index) => { + labels: zrUtil.map(parseTickLabelCustomValues(custom, scale), (numval, index) => { const tick = {value: numval}; return { - formattedLabel: labelFormatter(tick, index), - rawLabel: axis.scale.getLabel(tick), - tickValue: numval, - time: undefined as ScaleTick['time'] | NullUndefined, - break: undefined as VisualAxisBreak | NullUndefined, + formattedLabel: makeLabelFormatter(axis)(tick, index), + rawLabel: scale.getLabel(tick), + tick: tick, }; }), }; @@ -159,18 +139,34 @@ export function createAxisTicks( ticks: number[], tickCategoryInterval?: number } { + const scale = axis.scale; const custom = axis.getTickModel().get('customValues'); if (custom) { - const extent = axis.scale.getExtent(); - const tickNumbers = tickValuesToNumbers(axis, custom); return { - ticks: zrUtil.filter(tickNumbers, val => val >= extent[0] && val <= extent[1]) + ticks: parseTickLabelCustomValues(custom, scale) }; } // Only ordinal scale support tick interval return axis.type === 'category' ? makeCategoryTicks(axis, tickModel) - : {ticks: zrUtil.map(axis.scale.getTicks(opt), tick => tick.value)}; + : {ticks: zrUtil.map(scale.getTicks(opt), tick => tick.value)}; +} + +function parseTickLabelCustomValues( + customValues: AxisTickLabelCustomValuesOption, + scale: Scale, +): number[] { + const extent = scale.getExtent(); + const tickNumbers: number[] = []; + zrUtil.each(customValues, function (val) { + val = scale.parse(val); + if (val >= extent[0] && val <= extent[1]) { + tickNumbers.push(val); + } + }); + removeDuplicates(tickNumbers, removeDuplicatesGetKeyFromItemItself, null); + asc(tickNumbers); + return tickNumbers; } function makeCategoryLabels(axis: Axis, ctx: AxisLabelsComputingContext): ReturnType { @@ -260,7 +256,7 @@ function makeCategoryTicks(axis: Axis, tickModel: AxisBaseModel) { ); tickCategoryInterval = labelsResult.labelCategoryInterval; ticks = zrUtil.map(labelsResult.labels, function (labelItem) { - return labelItem.tickValue; + return labelItem.tick.value; }); } else { @@ -282,9 +278,7 @@ function makeRealNumberLabels(axis: Axis): ReturnType { return { formattedLabel: labelFormatter(tick, idx), rawLabel: axis.scale.getLabel(tick), - tickValue: tick.value, - time: tick.time, - break: tick.break, + tick: tick, }; }) }; @@ -487,7 +481,6 @@ function makeLabelsByNumericCategoryInterval( const labelFormatter = makeLabelFormatter(axis); const ordinalScale = axis.scale as OrdinalScale; const ordinalExtent = ordinalScale.getExtent(); - const labelModel = axis.getLabelModel(); const result: (AxisLabelInfoDetermined | number)[] = []; // TODO: axisType: ordinalTime, pick the tick from each month/day/year/... @@ -499,21 +492,14 @@ function makeLabelsByNumericCategoryInterval( // Calculate start tick based on zero if possible to keep label consistent // while zooming and moving while interval > 0. Otherwise the selection // of displayable ticks and symbols probably keep changing. - // 3 is empirical value. if (startTick !== 0 && step > 1 && tickCount / step > 2) { startTick = Math.round(Math.ceil(startTick / step) * step); } - // (1) Only add min max label here but leave overlap checking - // to render stage, which also ensure the returned list - // suitable for splitLine and splitArea rendering. - // (2) Scales except category always contain min max label so - // do not need to perform this process. - const showAllLabel = shouldShowAllLabels(axis); - const includeMinLabel = labelModel.get('showMinLabel') || showAllLabel; - const includeMaxLabel = labelModel.get('showMaxLabel') || showAllLabel; - - if (includeMinLabel && startTick !== ordinalExtent[0]) { + // min max labels may be excluded due to the previous modification of `startTick`. + // But they should be always included and the display strategy is adopted uniformly + // later in `AxisBuilder`. + if (startTick !== ordinalExtent[0]) { addItem(ordinalExtent[0]); } @@ -523,20 +509,18 @@ function makeLabelsByNumericCategoryInterval( addItem(tickValue); } - if (includeMaxLabel && tickValue - step !== ordinalExtent[1]) { + if (tickValue - step !== ordinalExtent[1]) { addItem(ordinalExtent[1]); } function addItem(tickValue: number) { - const tickObj = { value: tickValue }; + const tickObj = {value: tickValue}; result.push(onlyTick ? tickValue : { formattedLabel: labelFormatter(tickObj), rawLabel: ordinalScale.getLabel(tickObj), - tickValue: tickValue, - time: undefined, - break: undefined, + tick: tickObj, } ); } @@ -567,16 +551,14 @@ function makeLabelsByCustomizedCategoryInterval( zrUtil.each(ordinalScale.getTicks(), function (tick) { const rawLabel = ordinalScale.getLabel(tick); const tickValue = tick.value; - if (categoryInterval(tick.value, rawLabel)) { + if (categoryInterval(tickValue, rawLabel)) { result.push( onlyTick ? tickValue : { formattedLabel: labelFormatter(tick), rawLabel: rawLabel, - tickValue: tickValue, - time: undefined, - break: undefined, + tick: tick, } ); } diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 1c18aae1a7..698b309d81 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -851,7 +851,7 @@ function layOutGridByOuterBounds( if (labelInfoList) { for (let idx = 0; idx < labelInfoList.length; idx++) { const labelInfo = labelInfoList[idx]; - let proportion = axis.scale.normalize(getLabelInner(labelInfo.label).tickValue); + let proportion = axis.scale.normalize(getLabelInner(labelInfo.label).labelInfo.tick.value); proportion = xyIdx === 1 ? 1 - proportion : proportion; // xAxis use proportion on x, yAxis use proprotion on y, otherwise not. fillMarginOnOneDimension(labelInfo.rect, xyIdx, proportion); diff --git a/src/coord/matrix/Matrix.ts b/src/coord/matrix/Matrix.ts index 59cd7ecc9e..3e9bfacf3f 100644 --- a/src/coord/matrix/Matrix.ts +++ b/src/coord/matrix/Matrix.ts @@ -489,7 +489,7 @@ type CtxPointToData = { y: CtxPointToDataAreaType | NullUndefined; point: number[]; // If clamp required, this point is clamped after prepared. }; -// For handy performance optimization in pointToData. +// For quick performance optimization in pointToData. const _tmpCtxPointToData: CtxPointToData = {x: null, y: null, point: []}; function pointToDataOneDimPrepareCtx( diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 628ea1b1f8..1579d24520 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -642,7 +642,7 @@ function scaleRawExtentInfoReallyCreateDeal( // NOTE: This data may have been filtered by dataZoom on orthogonal axes. const data = seriesModel.getData(); if (data) { - const filter = scale.getSeriesExtentFilter ? scale.getSeriesExtentFilter() : null; + const filter = scale.getFilter ? scale.getFilter() : null; each(getDataDimensionsOnAxis(data, axisDim), function (dim) { unionExtentFromExtent(extent, data.getApproximateExtent(dim, filter)); }); diff --git a/src/core/CoordinateSystem.ts b/src/core/CoordinateSystem.ts index 5979adc85b..1f247fe6f1 100644 --- a/src/core/CoordinateSystem.ts +++ b/src/core/CoordinateSystem.ts @@ -28,6 +28,7 @@ import SeriesModel from '../model/Series'; import { error } from '../util/log'; import { CoordinateSystemDataCoord, NullUndefined } from '../util/types'; + type CoordinateSystemCreatorMap = {[type: string]: CoordinateSystemCreator}; /** @@ -59,6 +60,8 @@ class CoordinateSystemManager { this._nonSeriesBoxMasterList = dealCreate(nonSeriesBoxCoordSysCreators, true); this._normalMasterList = dealCreate(normalCoordSysCreators, false); + performAxisStatistics && performAxisStatistics(ecModel); + function dealCreate(creatorMap: CoordinateSystemCreatorMap, canBeNonSeriesBox: boolean) { let coordinateSystems: CoordinateSystemMaster[] = []; zrUtil.each(creatorMap, function (creator, type) { @@ -356,5 +359,10 @@ export const simpleCoordSysInjectionProvider: CoordSysInjectionProvider = functi return coordSysModel && coordSysModel.coordinateSystem; }; +let performAxisStatistics: ((ecModel: GlobalModel) => void) | NullUndefined; +// To reduce code size, the implementation of `performAxisStatistics` is registered only when needed. +export function registerPerformAxisStatistics(impl: typeof performAxisStatistics): void { + performAxisStatistics = impl; +} export default CoordinateSystemManager; diff --git a/src/core/echarts.ts b/src/core/echarts.ts index 18efa5c25b..e67e49dd61 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -160,10 +160,9 @@ const PRIORITY_PROCESSOR_DATASTACK = 900; // `PRIORITY_PROCESSOR_FILTER` is typically used by `dataZoom` (see `AxisProxy`), which relies // on the initialized "axis extent". const PRIORITY_PROCESSOR_FILTER = 1000; -// NOTICE: These "data processors" (especially, data filters) above may block the stream, so they -// should be put at the beginning of data processing. const PRIORITY_PROCESSOR_DEFAULT = 2000; const PRIORITY_PROCESSOR_STATISTIC = 5000; +// NOTICE: Data processors above block the stream (especially time-consuming processors like data filters). const PRIORITY_VISUAL_LAYOUT = 1000; const PRIORITY_VISUAL_PROGRESSIVE_LAYOUT = 1100; @@ -2928,6 +2927,9 @@ export function registerPreprocessor(preprocessorFunc: OptionPreprocessor): void } } +/** + * NOTICE: Alway run in block way (no progessive is allowed). + */ export function registerProcessor( priority: number | StageHandler | StageHandlerOverallReset, processor?: StageHandler | StageHandlerOverallReset diff --git a/src/data/DataStore.ts b/src/data/DataStore.ts index f0280d06a8..c9b7f932c0 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -27,10 +27,13 @@ import { ParsedValueNumeric } from '../util/types'; import { DataProvider } from './helper/dataProvider'; -import { parseDataValue } from './helper/dataValueHelper'; +import { + DataSanitizationFilter, parseDataValue, parseSanitizationFilter, passesSanitizationFilter +} from './helper/dataValueHelper'; import OrdinalMeta from './OrdinalMeta'; import { shouldRetrieveDataByName, Source } from './Source'; import { initExtentForUnion } from '../util/model'; +import { asc } from '../util/number'; const UNDEFINED = 'undefined'; /* global Float64Array, Int32Array, Uint32Array, Uint16Array */ @@ -73,9 +76,6 @@ type FilterCb = (...args: any) => boolean; // type MapArrayCb = (...args: any) => any; type MapCb = (...args: any) => ParsedValue | ParsedValue[]; -// g: greater than, ge: greater equal, l: less than, le: less equal -export type DataStoreExtentFilter = {g?: number; ge?: number; l?: number; le?: number;}; - export type DimValueGetter = ( this: DataStore, dataItem: any, @@ -509,7 +509,7 @@ class DataStore { * Get median of data in one dimension */ getMedian(dim: DimensionIndex): number { - const dimDataArray: ParsedValue[] = []; + const dimDataArray: number[] = []; // map all data of one dimension this.each([dim], function (val) { if (!isNaN(val as number)) { @@ -519,16 +519,14 @@ class DataStore { // TODO // Use quick select? - const sortedDimDataArray = dimDataArray.sort(function (a: number, b: number) { - return a - b; - }) as number[]; + asc(dimDataArray); const len = this.count(); // calculate median return len === 0 ? 0 : len % 2 === 1 - ? sortedDimDataArray[(len - 1) / 2] - : (sortedDimDataArray[len / 2] + sortedDimDataArray[len / 2 - 1]) / 2; + ? dimDataArray[(len - 1) / 2] + : (dimDataArray[len / 2] + dimDataArray[len / 2 - 1]) / 2; } /** @@ -1134,7 +1132,7 @@ class DataStore { getDataExtent( dim: DimensionIndex, - filter: DataStoreExtentFilter | NullUndefined + filter: DataSanitizationFilter | NullUndefined ): [number, number] { // Make sure use concrete dim as cache name. const dimData = this._chunks[dim]; @@ -1165,29 +1163,10 @@ class DataStore { const thisExtent = this._extent; const dimExtentRecord = thisExtent[dim] || (thisExtent[dim] = {}); - let filterKey = ''; - let filterG = -Infinity; - let filterGE = -Infinity; - let filterL = Infinity; - let filterLE = Infinity; - if (filter) { - if (filter.g != null) { - filterKey += 'G' + filter.g; - filterG = filter.g; - } - if (filter.ge != null) { - filterKey += 'GE' + filter.ge; - filterGE = filter.ge; - } - if (filter.l != null) { - filterKey += 'L' + filter.l; - filterL = filter.l; - } - if (filter.le != null) { - filterKey += 'LE' + filter.le; - filterLE = filter.le; - } - } + + const filterParsed = parseSanitizationFilter(filter); + const filterKey = filterParsed.key; + const dimExtent = dimExtentRecord[filterKey]; if (dimExtent) { return dimExtent.slice() as [number, number]; @@ -1196,24 +1175,18 @@ class DataStore { let min = initialExtent[0]; let max = initialExtent[1]; - // NOTICE: Performance sensitive on large data. for (let i = 0; i < currEnd; i++) { + // NOTICE: Manually inline some code for performance of large data. const rawIdx = this.getRawIndex(i); const value = dimData[rawIdx] as ParsedValueNumeric; - if (filter) { - if (value <= filterG - || value < filterGE - || value >= filterL - || value > filterLE - ) { - continue; + // NOTE: in most cases, filter does not exist. + if (!filter || passesSanitizationFilter(filterParsed, value)) { + if (value < min) { + min = value; + } + if (value > max) { + max = value; } - } - if (value < min) { - min = value; - } - if (value > max) { - max = value; } } diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index ec8bc0b155..1cfde0407f 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -44,8 +44,9 @@ import type Tree from './Tree'; import type { VisualMeta } from '../component/visualMap/VisualMapModel'; import {isSourceInstance, Source} from './Source'; import { LineStyleProps } from '../model/mixin/lineStyle'; -import DataStore, { DataStoreDimensionDefine, DataStoreExtentFilter, DimValueGetter } from './DataStore'; +import DataStore, { DataStoreDimensionDefine, DimValueGetter } from './DataStore'; import { isSeriesDataSchema, SeriesDataSchema } from './helper/SeriesDataSchema'; +import { DataSanitizationFilter } from './helper/dataValueHelper'; const isObject = zrUtil.isObject; const map = zrUtil.map; @@ -679,7 +680,7 @@ class SeriesData< */ getApproximateExtent( dim: SeriesDimensionLoose, - filter: DataStoreExtentFilter | NullUndefined + filter: DataSanitizationFilter | NullUndefined ): [number, number] { return this._approximateExtent[dim] || this._store.getDataExtent(this._getStoreDimIndex(dim), filter); } diff --git a/src/data/helper/createDimensions.ts b/src/data/helper/createDimensions.ts index b8f8e8a9ef..ba88b6aa4c 100644 --- a/src/data/helper/createDimensions.ts +++ b/src/data/helper/createDimensions.ts @@ -34,7 +34,7 @@ import { import OrdinalMeta from '../OrdinalMeta'; import { createSourceFromSeriesDataOption, isSourceInstance, Source } from '../Source'; import { CtorInt32Array } from '../DataStore'; -import { normalizeToArray } from '../../util/model'; +import { normalizeToArray, removeDuplicates } from '../../util/model'; import { BE_ORDINAL, guessOrdinal } from './sourceHelper'; import { createDimNameMap, ensureSourceDimNameMap, SeriesDataSchema, shouldOmitUnusedDimensions @@ -340,7 +340,18 @@ export default function prepareSeriesDataSchema( resultList.sort((item0, item1) => item0.storeDimIndex - item1.storeDimIndex); } - removeDuplication(resultList); + removeDuplicates( + resultList, + function (item) { + return item.name; + }, + function (item, existingCount) { + if (existingCount > 0) { + // Starts from 0. + item.name = item.name + (existingCount - 1); + } + } + ); return new SeriesDataSchema({ source, @@ -350,21 +361,6 @@ export default function prepareSeriesDataSchema( }); } -function removeDuplication(result: SeriesDimensionDefine[]) { - const duplicationMap = createHashMap(); - for (let i = 0; i < result.length; i++) { - const dim = result[i]; - const dimOriginalName = dim.name; - let count = duplicationMap.get(dimOriginalName) || 0; - if (count > 0) { - // Starts from 0. - dim.name = dimOriginalName + (count - 1); - } - count++; - duplicationMap.set(dimOriginalName, count); - } -} - // ??? TODO // Originally detect dimCount by data[0]. Should we // optimize it to only by sysDims and dimensions and encode. diff --git a/src/data/helper/dataValueHelper.ts b/src/data/helper/dataValueHelper.ts index 18764920fa..a45b904457 100644 --- a/src/data/helper/dataValueHelper.ts +++ b/src/data/helper/dataValueHelper.ts @@ -17,12 +17,14 @@ * under the License. */ -import { ParsedValue, DimensionType } from '../../util/types'; +import { ParsedValue, DimensionType, NullUndefined } from '../../util/types'; import { parseDate, numericToNumber } from '../../util/number'; import { createHashMap, trim, hasOwn, isString, isNumber } from 'zrender/src/core/util'; import { throwError } from '../../util/log'; +// --------- START: Parsers -------- + /** * Convert raw the value in to inner value in List. * @@ -95,8 +97,12 @@ export function getRawValueParser(type: RawValueParserType): RawValueParser { return valueParserMap.get(type); } +// --------- END: Parsers --------- + +// --------- START: Data transformattion filters --------- +// (comprehensive and performance insensitive) export interface FilterComparator { evaluate(val: unknown): boolean; @@ -261,3 +267,68 @@ export function createFilterComparator( ? new FilterOrderComparator(op as OrderRelationOperator, rval) : null; } + +// --------- END: Data transformattion filters --------- + + + +// --------- START: Data store sanitization filters --------- +// (simple and performance sensitive) + +// g: greater than, ge: greater equal, l: less than, le: less equal +export type DataSanitizationFilter = {g?: number; ge?: number; l?: number; le?: number;}; +type DataSanitizationFilterParsed = {key: string; g: number; ge: number; l: number; le: number;}; + +/** + * @usage + * const filterParsed = parseSanitizationFilter(filter); + * for( ... ) { + * const val = ...; + * if (!filter || passesFilter(filterParsed, val)) { + * // normal handling + * } + * } + */ +export function parseSanitizationFilter( + filter: DataSanitizationFilter | NullUndefined +): DataSanitizationFilterParsed { + let filterKey = ''; + let filterG = -Infinity; + let filterGE = -Infinity; + let filterL = Infinity; + let filterLE = Infinity; + if (filter) { + if (filter.g != null) { + filterKey += 'G' + filter.g; + filterG = filter.g; + } + if (filter.ge != null) { + filterKey += 'GE' + filter.ge; + filterGE = filter.ge; + } + if (filter.l != null) { + filterKey += 'L' + filter.l; + filterL = filter.l; + } + if (filter.le != null) { + filterKey += 'LE' + filter.le; + filterLE = filter.le; + } + } + return { + key: filterKey, + g: filterG, + ge: filterGE, + l: filterL, + le: filterLE, + }; +} + +export function passesSanitizationFilter(filterParsed: DataSanitizationFilterParsed, value: number): boolean { + return value > filterParsed.g + || value >= filterParsed.ge + || value < filterParsed.l + || value <= filterParsed.le; +} + +// --------- END: Data store sanitization filters --------- diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index 88c0336001..6d1616fc13 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -17,7 +17,7 @@ * under the License. */ -import { each, defaults, hasOwn, assert } from 'zrender/src/core/util'; +import { each, defaults, hasOwn } from 'zrender/src/core/util'; import { isNullableNumberFinite, mathAbs, mathMax, mathMin, parsePercent } from '../util/number'; import { isDimensionStacked } from '../data/helper/dataStackHelper'; import createRenderPlanner from '../chart/helper/createRenderPlanner'; @@ -28,13 +28,13 @@ import { StageHandler, NullUndefined } from '../util/types'; import { createFloat32Array } from '../util/vendor'; import { extentHasValue, - getCachePerECFullUpdate, GlobalModelCachePerECFullUpdate, initExtentForUnion, - isValidNumberForExtent, makeCallOnlyOnce, makeInner, + initExtentForUnion, + makeCallOnlyOnce, unionExtentFromNumber, } from '../util/model'; import { isOrdinalScale } from '../scale/helper'; import { - CartesianAxisHashKey, getCartesianAxisHashKey, isCartesian2DInjectedAsDataCoordSys + isCartesian2DInjectedAsDataCoordSys } from '../coord/cartesian/cartesianAxisHelper'; import type BaseBarSeriesModel from '../chart/bar/BaseBarSeries'; import type BarSeriesModel from '../chart/bar/BarSeries'; @@ -42,47 +42,19 @@ import { AxisContainShapeHandler, registerAxisContainShapeHandler, } from '../coord/scaleRawExtentInfo'; import { EChartsExtensionInstallRegisters } from '../extension'; -import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; -import type Scale from '../scale/Scale'; - - -const ecModelCacheInner = makeInner<{ - layoutPre: BarGridLayoutPre; -}, GlobalModelCachePerECFullUpdate>(); +import { + AxisStatisticsClient, AxisStatisticsKey, eachCollectedAxis, + eachCollectedSeries, getCollectedSeriesLength, requireAxisStatistics +} from '../coord/axisStatistics'; +import { + AXIS_BAND_WIDTH_KIND_NORMAL, AxisBandWidthResult, calcBandWidth +} from '../coord/axisBand'; -// Record of layout preparation by series sub type. -type BarGridLayoutPre = Partial>; -type BarGridLayoutPreOnSeriesType = { - seriesReady: boolean; - // NOTICE: `axes` and `axisMap` do not necessarily contain all Cartesian axes - a record - // is created iff `ensureLayoutAxisPre` is called. - axes: CartesianAxisHashKey[]; - axisMap: Record; -}; - -// Record of layout preparation by series sub type by axis. -type BarGridLayoutAxisPre = { - axis: Axis2D; - // This is series use this axis as base axis and need to be laid out. - seriesList: BaseBarSeriesModel[]; - // Statistics on values for `minGap` and `linearValueExtent` has been ready. - valStatReady?: boolean; - linearMinGap?: number | NullUndefined; - // min/max of values of all bar series (per `BaseBarSeriesSubType`) on this axis, - // but other series types are not included. - // Only available for non-'category' axis. - // If no valid data, remains `undefined`. - linearValueExtent?: number[] | NullUndefined; -}; +const callOnlyOnce = makeCallOnlyOnce(); const STACK_PREFIX = '__ec_stack_'; -// Arbitrary, leave some space to avoid overflowing when dataZoom moving. -const SINGULAR_BAND_WIDTH_RATIO = 0.8; -// Corresponding to `SINGULAR_BAND_WIDTH_RATIO`, but they are not necessarily equal on other value choices. -const SINGULAR_SUPPLEMENT_RATIO = 0.8; - function getSeriesStackId(seriesModel: BaseBarSeriesModel): string { return (seriesModel as BarSeriesModel).get('stack') || STACK_PREFIX + seriesModel.seriesIndex; } @@ -90,10 +62,7 @@ function getSeriesStackId(seriesModel: BaseBarSeriesModel): string { interface BarGridLayoutAxisInfo { seriesInfo: BarGridLayoutAxisSeriesInfo[]; // Calculated layout width for a single bars group. - bandWidth: number; - singular?: boolean; - linearValueExtent?: BarGridLayoutAxisPre['linearValueExtent']; - pxToDataRatio?: number | NullUndefined; + bandWidthResult: AxisBandWidthResult; } interface BarGridLayoutAxisSeriesInfo { @@ -132,7 +101,7 @@ export type BarGridColumnLayoutOnAxis = BarGridLayoutAxisInfo & { }; type BarGridLayoutResultItemInternal = { - bandWidth: BarGridLayoutAxisInfo['bandWidth'] + bandWidth: BarGridLayoutAxisInfo['bandWidthResult']['bandWidth'] offset: number // An offset with respect to `dataToPoint` width: number }; @@ -146,12 +115,8 @@ export type BarGridLayoutResultForCustomSeries = BarGridLayoutResultItem[] | Nul */ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultForCustomSeries { const params: BarGridLayoutAxisSeriesInfo[] = []; - const baseAxis = opt.axis; - - if (baseAxis.type !== 'category') { - return; - } - const bandWidth = baseAxis.getBandWidth(); + const bandWidthResult: AxisBandWidthResult = {}; + calcBandWidth(bandWidthResult, opt.axis); for (let i = 0; i < opt.count || 0; i++) { params.push(defaults({ @@ -159,7 +124,7 @@ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultFo }, opt) as BarGridLayoutAxisSeriesInfo); } const widthAndOffsets = calcBarWidthAndOffset({ - bandWidth, + bandWidthResult, seriesInfo: params, }); @@ -173,130 +138,6 @@ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultFo return result; } -function ensureLayoutPre( - ecModel: GlobalModel, seriesType: BaseBarSeriesSubType -): BarGridLayoutPreOnSeriesType { - const ecCache = ecModelCacheInner(getCachePerECFullUpdate(ecModel)); - const layoutPre = ecCache.layoutPre || (ecCache.layoutPre = {}); - return layoutPre[seriesType] || (layoutPre[seriesType] = { - axes: [], axisMap: {}, seriesReady: false - }); -} - -function ensureLayoutAxisPre( - layoutPre: BarGridLayoutPreOnSeriesType, axis: Axis2D -): BarGridLayoutAxisPre { - const axisKey = getCartesianAxisHashKey(axis); - const axisMap = layoutPre.axisMap || (layoutPre.axisMap = {}); - let axisPre = axisMap[axisKey]; - if (!axisPre) { - layoutPre.axes.push(axisKey); - axisPre = axisMap[axisKey] = { - axis, - seriesList: [], - }; - } - return axisPre; -} - -function eachAxisPre( - layoutPre: BarGridLayoutPreOnSeriesType, cb: (axisPre: BarGridLayoutAxisPre) => void -): void { - each(layoutPre.axes, function (axisKey) { - cb(layoutPre.axisMap[axisKey]); - }); -} - -/** - * NOTICE: - * - Ensure the idempotent on this function - it may be called multiple times in a run - * of ec workflow. - * - Not a pure function - `seriesListByType` will be cached on base axis instance - * to avoid duplicated travel of series for each axis. - * - The order of series matters - must be respected to the declaration on ec option, - * because for historical reason, the last series holds the effective ec option. - * See `calcBarWidthAndOffset`. - */ -function ensureBarGridSeriesList( - ecModel: GlobalModel, seriesType: BaseBarSeriesSubType -): BarGridLayoutPreOnSeriesType { - const layoutPre = ensureLayoutPre(ecModel, seriesType); - if (layoutPre.seriesReady) { - return layoutPre; - } - ecModel.eachSeriesByType(seriesType, function (seriesModel: BaseBarSeriesModel) { - if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { - const baseAxis = (seriesModel.coordinateSystem as Cartesian2D).getBaseAxis(); - ensureLayoutAxisPre(layoutPre, baseAxis).seriesList.push(seriesModel); - } - }); - layoutPre.seriesReady = true; - return layoutPre; -} - -/** - * CAVEAT: Time-consuming due to the travel and sort of series data. - * - * Map from (baseAxis.dim + '_' + baseAxis.index) to min gap of two adjacent - * values. - * This works for time axes, value axes, and log axes. - * For a single time axis, return value is in the form like - * {'x_0': [1000000]}. - * The value of 1000000 is in milliseconds. - */ -function ensureValuesStatisticsOnAxis( - axis: Axis2D, layoutPre: BarGridLayoutPreOnSeriesType -): BarGridLayoutAxisPre { - if (__DEV__) { - assert(!isOrdinalScale(axis.scale)); - } - - const axisPre = ensureLayoutAxisPre(layoutPre, axis); - // `minGap` is cached for performance, otherwise data will be traveled more than once - // in each run of ec workflow. The first creation is during coord sys update stage to - // expand the scale extent of the base axis to avoid edge bars overflowing the axis. - // And then in render stage. - if (axisPre.valStatReady) { - return axisPre; - } - - const scale = axis.scale; - const values: number[] = []; - const linearValueExtent = initExtentForUnion(); - each(axisPre.seriesList, function (seriesModel) { - const data = seriesModel.getData(); - const dimIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); - const store = data.getStore(); - for (let i = 0, cnt = store.count(); i < cnt; ++i) { - const val = scale.transformIn(store.get(dimIdx, i) as number, null); - if (isValidNumberForExtent(val)) { // This also filters out `log(non-positive)` for LogScale. - values.push(val); - unionExtentFromNumber(linearValueExtent, val); - } - } - }); - - // Sort axis values into ascending order to calculate gaps - values.sort(function (a, b) { - return a - b; - }); - let min = null; - for (let j = 1; j < values.length; ++j) { - const delta = values[j] - values[j - 1]; - if (delta > 0) { - // Ignore 0 delta because they are of the same axis value - min = min === null ? delta : mathMin(min, delta); - } - } - axisPre.linearMinGap = min; // Set to null if only have one data - if (extentHasValue(linearValueExtent)) { - axisPre.linearValueExtent = linearValueExtent; // Remain `undefined` if no valid data - } - axisPre.valStatReady = true; - - return axisPre; -} - /** * NOTICE: This layout is based on axis pixel extent and scale extent. * It may be used on estimation, where axis pixel extent and scale extent @@ -304,12 +145,11 @@ function ensureValuesStatisticsOnAxis( * axis pixel extent and scale extent may be changed finally. */ function makeColumnLayoutOnAxisReal( - layoutPre: BarGridLayoutPreOnSeriesType, baseAxis: Axis2D, + seriesType: BaseBarSeriesSubType ): BarGridColumnLayoutOnAxis { - const axisPre = ensureLayoutAxisPre(layoutPre, baseAxis); const seriesInfoListOnAxis = createLayoutInfoListOnAxis( - axisPre.axis, layoutPre, axisPre + baseAxis, seriesType ) as BarGridColumnLayoutOnAxis; seriesInfoListOnAxis.columnMap = calcBarWidthAndOffset(seriesInfoListOnAxis); return seriesInfoListOnAxis; @@ -317,41 +157,15 @@ function makeColumnLayoutOnAxisReal( function createLayoutInfoListOnAxis( baseAxis: Axis2D, - layoutPre: BarGridLayoutPreOnSeriesType, - axisPre: BarGridLayoutAxisPre + seriesType: BaseBarSeriesSubType ): BarGridLayoutAxisInfo { const seriesInfoOnAxis: BarGridLayoutAxisSeriesInfo[] = []; - const axisScale = baseAxis.scale; - let linearValueExtent: BarGridLayoutAxisInfo['linearValueExtent']; - let pxToDataRatio: BarGridLayoutAxisInfo['pxToDataRatio']; - let singular: BarGridLayoutAxisInfo['singular']; - - let bandWidth: number; - if (isOrdinalScale(axisScale)) { - bandWidth = baseAxis.getBandWidth(); - } - else { - const axisPre = ensureValuesStatisticsOnAxis(baseAxis, layoutPre); - linearValueExtent = axisPre.linearValueExtent; - const axisExtent = baseAxis.getExtent(); - // Always use a new pxSpan because it may be changed in `grid` contain label calculation. - const pxSpan = mathAbs(axisExtent[1] - axisExtent[0]); - const linearScaleSpan = getScaleLinearSpanForMapping(axisScale); - // `linearScaleSpan` may be `0` or `Infinity` or `NaN`, since normalizers like - // `intervalScaleEnsureValidExtent` may not have been called yet. - if (axisPre.linearMinGap && linearScaleSpan && isNullableNumberFinite(linearScaleSpan)) { - singular = false; - bandWidth = pxSpan / linearScaleSpan * axisPre.linearMinGap; - pxToDataRatio = linearScaleSpan / pxSpan; - } - else { - singular = true; - bandWidth = pxSpan * SINGULAR_BAND_WIDTH_RATIO; - } - } + const bandWidthResult: AxisBandWidthResult = {}; + calcBandWidth(bandWidthResult, baseAxis); + const bandWidth = bandWidthResult.bandWidth; - each(axisPre.seriesList, function (seriesModel) { + eachCollectedSeries(baseAxis, axisStatKey(seriesType), function (seriesModel: BaseBarSeriesModel) { seriesInfoOnAxis.push({ barWidth: parsePercent(seriesModel.get('barWidth'), bandWidth), barMaxWidth: parsePercent(seriesModel.get('barMaxWidth'), bandWidth), @@ -368,11 +182,8 @@ function createLayoutInfoListOnAxis( }); return { - bandWidth: bandWidth, - linearValueExtent: linearValueExtent, + bandWidthResult, seriesInfo: seriesInfoOnAxis, - singular: singular, - pxToDataRatio: pxToDataRatio, }; } @@ -392,7 +203,7 @@ function calcBarWidthAndOffset( minWidth?: number } - const bandWidth = seriesInfoOnAxis.bandWidth; + const bandWidth = seriesInfoOnAxis.bandWidthResult.bandWidth; let remainedWidth = bandWidth; let autoWidthCount: number = 0; let barCategoryGapOption: number | string; @@ -530,10 +341,9 @@ function calcBarWidthAndOffset( export function layout(seriesType: BaseBarSeriesSubType, ecModel: GlobalModel): void { - const layoutPre = ensureBarGridSeriesList(ecModel, seriesType); - eachAxisPre(layoutPre, function (axisPre) { - const columnLayout = makeColumnLayoutOnAxisReal(layoutPre, axisPre.axis); - each(axisPre.seriesList, function (seriesModel) { + eachCollectedAxis(ecModel, function (axis) { + const columnLayout = makeColumnLayoutOnAxisReal(axis as Axis2D, seriesType); + eachCollectedSeries(axis, axisStatKey(seriesType), function (seriesModel) { const columnLayoutInfo = columnLayout.columnMap[getSeriesStackId(seriesModel)]; seriesModel.getData().setLayout({ bandWidth: columnLayoutInfo.bandWidth, @@ -707,26 +517,26 @@ function barGridCreateAxisContainShapeHandler(seriesType: BaseBarSeriesSubType): // If bars are placed on 'time', 'value', 'log' axis, handle bars overflow here. // See #6728, #4862, `test/bar-overflow-time-plot.html` if (axis && axis instanceof Axis2D && !isOrdinalScale(scale)) { - const layoutPre = ensureBarGridSeriesList(ecModel, seriesType); - const axisPre = ensureLayoutAxisPre(layoutPre, axis); - if (!axisPre.seriesList.length) { - return; // Quick return for robustness - in most cases there is no bar series based on this axis. + if (!getCollectedSeriesLength(axis, axisStatKey(seriesType))) { + return; // Quick path - in most cases there is no bar on non-ordinal axis. } - const columnLayout = makeColumnLayoutOnAxisReal(layoutPre, axis); - return calcShapeOverflowSupplement(scale, columnLayout); + const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); + return calcShapeOverflowSupplement(columnLayout); } }; } function calcShapeOverflowSupplement( - scale: Scale, columnLayout: BarGridColumnLayoutOnAxis | NullUndefined ): number[] | NullUndefined { - const linearValueExtent = columnLayout && columnLayout.linearValueExtent; - - if (columnLayout == null || !linearValueExtent) { + if (columnLayout == null) { return; } + const bandWidthResult = columnLayout.bandWidthResult; + const bandWidthResultKind = bandWidthResult.kind; + if (bandWidthResultKind == null) { + return; // No series data. + } // The calculation below is based on a proportion mapping from // `[barsBoundVal[0], barsBoundVal[1]]` to `[minValNew, maxValNew]`: @@ -737,7 +547,7 @@ function calcShapeOverflowSupplement( // (Note: `|---|` above represents "pixels" rather than "data".) const barsBoundPx = initExtentForUnion(); - const bandWidth = columnLayout.bandWidth; + const bandWidth = bandWidthResult.bandWidth; // Union `-bandWidth / 2` and `bandWidth / 2` to provide extra space for visually preferred, // Otherwise the bars on the edges may overlap with axis line. // And it also includes `0`, which ensures `barsBoundPx[0] <= 0 <= barsBoundPx[1]`. @@ -750,30 +560,58 @@ function calcShapeOverflowSupplement( unionExtentFromNumber(barsBoundPx, item.offset + item.width); }); - const pxToDataRatio = columnLayout.pxToDataRatio; + const ratio = bandWidthResult.ratio; + if (extentHasValue(barsBoundPx) && isNullableNumberFinite(ratio) + && bandWidthResultKind === AXIS_BAND_WIDTH_KIND_NORMAL + ) { + // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on + // `minGap` and extent on data domain. + return [barsBoundPx[0] * ratio, barsBoundPx[1] * ratio]; + // If AXIS_BAND_WIDTH_KIND_SINGULAR, extent expansion is not needed. + } +} - if (extentHasValue(barsBoundPx)) { - let linearSupplement: number[]; +function createAxisStatisticsClient(seriesType: BaseBarSeriesSubType): AxisStatisticsClient { + return { + /** + * NOTICE: + * The order of series matters - must be respected to the declaration on ec option, + * because for historical reason, the last series holds the effective ec option. + * See `calcBarWidthAndOffset`. + */ + collectAxisSeries(ecModel, saveAxisSeries) { + ecModel.eachSeriesByType(seriesType, function (seriesModel: BaseBarSeriesModel) { + if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { + saveAxisSeries( + (seriesModel.coordinateSystem as Cartesian2D).getBaseAxis(), + seriesModel + ); + } + }); + }, - if (columnLayout.singular) { - const linearSpan = getScaleLinearSpanForMapping(scale); - linearSupplement = [-linearSpan * SINGULAR_SUPPLEMENT_RATIO, linearSpan * SINGULAR_SUPPLEMENT_RATIO]; - } - else if (isNullableNumberFinite(pxToDataRatio)) { - // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on - // `minGap` and extent on data domain. - linearSupplement = [barsBoundPx[0] * pxToDataRatio, barsBoundPx[1] * pxToDataRatio]; + getMetrics(axis) { + return { + minGap: !isOrdinalScale(axis.scale) + }; } - return linearSupplement; - } + }; } -const callOnlyOnce = makeCallOnlyOnce(); +function axisStatKey(seriesType: BaseBarSeriesSubType): AxisStatisticsKey { + return `barGrid-${seriesType}` as AxisStatisticsKey; +} -export function registerBarGridAxisContainShapeHandler(registers: EChartsExtensionInstallRegisters) { +export function registerBarGridAxisHandlers(registers: EChartsExtensionInstallRegisters) { callOnlyOnce(registers, function () { - registerAxisContainShapeHandler('bar', barGridCreateAxisContainShapeHandler('bar')); - registerAxisContainShapeHandler('pictorialBar', barGridCreateAxisContainShapeHandler('pictorialBar')); + + function register(seriesType: BaseBarSeriesSubType): void { + requireAxisStatistics(axisStatKey(seriesType), createAxisStatisticsClient(seriesType)); + registerAxisContainShapeHandler(seriesType, barGridCreateAxisContainShapeHandler(seriesType)); + } + + register('bar'); + register('pictorialBar'); }); } diff --git a/src/scale/Log.ts b/src/scale/Log.ts index da746696de..8adff48efa 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -161,6 +161,10 @@ class LogScale extends Scale { static mapperMethods: DecoratedScaleMapperMethods = { + needTransform() { + return true; + }, + normalize(val) { return this.intervalStub.normalize(logScaleLogTick(val, this.base)); }, @@ -228,7 +232,7 @@ class LogScale extends Scale { ); }, - getSeriesExtentFilter() { + getFilter() { return {g: 0}; }, diff --git a/src/scale/Ordinal.ts b/src/scale/Ordinal.ts index e6d2f13d52..d4e2fc55b5 100644 --- a/src/scale/Ordinal.ts +++ b/src/scale/Ordinal.ts @@ -177,6 +177,10 @@ class OrdinalScale extends Scale { static decoratedMethods: DecoratedScaleMapperMethods = { + needTransform() { + return this._mapper.needTransform(); + }, + contain(this: OrdinalScale, val: OrdinalNumber): boolean { return this._mapper.contain(this._getTickNumber(val)) && val >= 0 && val < this._ordinalMeta.categories.length; diff --git a/src/scale/Time.ts b/src/scale/Time.ts index de1ef75728..d7f1992181 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -90,6 +90,7 @@ import { getScaleExtentForTickUnsafe, initBreakOrLinearMapper, ScaleMapperGeneric } from './scaleMapper'; +import { removeDuplicates, removeDuplicatesGetKeyFromValueProp } from '../util/model'; // FIXME 公用? const bisect = function ( @@ -191,17 +192,7 @@ class TimeScale extends Scale { return ticks; } - const extent0Unit = getUnitFromValue(extent[1], useUTC); - ticks.push({ - value: extent[0], - time: { - level: 0, - upperTimeUnit: extent0Unit, - lowerTimeUnit: extent0Unit, - } - }); - - const innerTicks = getIntervalTicks( + ticks = createIntervalTicks( this._minLevelUnit, this._approxInterval, useUTC, @@ -210,18 +201,6 @@ class TimeScale extends Scale { brk ); - ticks = ticks.concat(innerTicks); - - const extent1Unit = getUnitFromValue(extent[1], useUTC); - ticks.push({ - value: extent[1], - time: { - level: 0, - upperTimeUnit: extent1Unit, - lowerTimeUnit: extent1Unit, - } - }); - let upperUnitIndex = primaryTimeUnits.length - 1; let maxLevel = 0; each(ticks, tick => { @@ -489,7 +468,7 @@ function createEstimateNiceMultiple( }; } -function getIntervalTicks( +function createIntervalTicks( bottomUnitName: TimeUnit, approxInterval: number, isUTC: boolean, @@ -709,8 +688,9 @@ function getIntervalTicks( return filter(levelTicks, tick => tick.value >= extent[0] && tick.value <= extent[1] && !tick.notAdd); }), levelTicks => levelTicks.length > 0); - const ticks: TimeScaleTick[] = []; const maxLevel = levelsTicksInExtent.length - 1; + const ticks: TimeScaleTick[] = []; + for (let i = 0; i < levelsTicksInExtent.length; ++i) { const levelTicks = levelsTicksInExtent[i]; for (let k = 0; k < levelTicks.length; ++k) { @@ -726,16 +706,31 @@ function getIntervalTicks( } } + // Remove duplicates, which may cause jitter of `splitArea` and other bad cases. + removeDuplicates(ticks, removeDuplicatesGetKeyFromValueProp, null); + ticks.sort((a, b) => a.value - b.value); - // Remove duplicates - const result: TimeScaleTick[] = []; - for (let i = 0; i < ticks.length; ++i) { - if (i === 0 || ticks[i].value !== ticks[i - 1].value) { - result.push(ticks[i]); - } + + const currMinTick = ticks[0]; + const currMaxTick = ticks[ticks.length - 1]; + const extent0Unit = getUnitFromValue(extent[0], isUTC); + const extent1Unit = getUnitFromValue(extent[1], isUTC); + if (!currMinTick || currMinTick.value > extent[0]) { + ticks.unshift({ + value: extent[0], + time: {level: 0, upperTimeUnit: extent0Unit, lowerTimeUnit: extent0Unit}, + notNice: true, + }); + } + if (!currMaxTick || currMaxTick.value < extent[1]) { + ticks.push({ + value: extent[1], + time: {level: 0, upperTimeUnit: extent1Unit, lowerTimeUnit: extent1Unit}, + notNice: true, + }); } - return result; + return ticks; } export const calcNiceForTimeScale: ScaleCalcNiceMethod = function (scale: TimeScale, opt) { diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index f0f2e5ba21..394092c82f 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -105,6 +105,10 @@ class BreakScaleMapperImpl { static decoratedMethods: DecoratedScaleMapperMethods = { + needTransform() { + return !this.breaks.length; + }, + getExtent() { return this._outOfBrk.getExtent(); }, diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts index 57b06e1611..08da294e9f 100644 --- a/src/scale/scaleMapper.ts +++ b/src/scale/scaleMapper.ts @@ -23,7 +23,7 @@ import { NullUndefined } from '../util/types'; import { AxisBreakParsingResult, BreakScaleMapper, getScaleBreakHelper } from './break'; import { error } from '../util/log'; import { ValueTransformLookupOpt } from './helper'; -import { DataStoreExtentFilter } from '../data/DataStore'; +import { DataSanitizationFilter } from '../data/helper/dataValueHelper'; // ------ START: Scale Mapper Core ------ @@ -34,7 +34,6 @@ import { DataStoreExtentFilter } from '../data/DataStore'; * - All tick/label-related calculation. * - `dataZoom` controlled ends. * - Cartesian2D `clampData`. - * - `axisPointer` triggering. * - line series start. * - heatmap series range. * - markerArea range. @@ -58,6 +57,7 @@ import { DataStoreExtentFilter } from '../data/DataStore'; * - `grid` boundary related calculation in view rendering, such as, `barGrid` calculates * `barWidth` for numeric scales based on the data extent. * - Axis line position determination (such as `canOnZeroToAxis`); + * - `axisPointer` triggering (otherwise users may be confused if using `SCALE_EXTENT_KIND_EFFECTIVE`). * `SCALE_EXTENT_KIND_MAPPING` can be absent, which can be used to determine whether it is used. * * Illustration: @@ -72,6 +72,7 @@ export const SCALE_EXTENT_KIND_MAPPING = 1; const SCALE_MAPPER_METHOD_NAMES_MAP: Record = { + needTransform: 1, normalize: 1, scale: 1, transformIn: 1, @@ -81,7 +82,7 @@ const SCALE_MAPPER_METHOD_NAMES_MAP: Record = { getExtentUnsafe: 1, setExtent: 1, setExtent2: 1, - getSeriesExtentFilter: 1, + getFilter: 1, sanitizeExtent: 1, freeze: 1, }; @@ -148,6 +149,12 @@ export type ScaleMapperTransformInOpt = export interface ScaleMapper extends ScaleMapperGeneric {} export interface ScaleMapperGeneric { + /** + * Enable a fast path in large data traversal - the call of `transformIn`/`transformOut` + * can be omitted, and this is the most case. + */ + needTransform(this: This): boolean; + /** * Normalize a value to linear [0, 1], return 0.5 if extent span is 0. * The typical logic is: @@ -240,7 +247,10 @@ export interface ScaleMapperGeneric { setExtent(this: This, start: number, end: number): void; setExtent2(this: This, kind: ScaleExtentKind, start: number, end: number): void; - getSeriesExtentFilter?: () => DataStoreExtentFilter; + /** + * Filter for sanitization. + */ + getFilter?: () => DataSanitizationFilter; /** * Sanitize the input extent if possible. For example, for LogScale, the negative part will be clampped. @@ -383,6 +393,10 @@ export function initLinearScaleMapper( const linearScaleMapperMethods: ScaleMapperGeneric = { + needTransform() { + return false; + }, + /** * NOTICE: Don't use optional arguments for performance consideration here. */ @@ -408,7 +422,9 @@ const linearScaleMapperMethods: ScaleMapperGeneric = { }, contain(val) { - const extent = this._extents[SCALE_EXTENT_KIND_EFFECTIVE]; + // This method is typically used in axis trigger and markers. + // Users may be confused if the extent is restricted to `SCALE_EXTENT_KIND_EFFECTIVE`. + const extent = getScaleExtentForMappingUnsafe(this, null); return val >= extent[0] && val <= extent[1]; }, diff --git a/src/util/model.ts b/src/util/model.ts index 67ef0282d2..54b42de241 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -1239,6 +1239,7 @@ export function isValidBoundsForExtent(start: number, end: number): boolean { /** * `extent` should be initialized by `initExtentForUnion()`, and unioned by `unionExtent()`. + * `extent` may contain `Infinity` / `NaN`, but assume no `null`/`undefined`. */ export function extentHasValue(extent: number[]): boolean { // Also considered extent may have `NaN` and `Infinity`. @@ -1294,10 +1295,11 @@ export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { * The cache is auto cleared at the begining of a run of "ec prepare". * * NOTICE: - * - It can be only called at "ec prepare" stage, such as, - * - Do not call it in processor `getTargetSeries` methods. - * - Do not call it in component/series model `init`/`mergeOption`/`optionUpdated`/`getData` methods. - * - "ec prepare" is not necessarily called before each "ec full update". + * - The cache can only be written at the "ec prepare" stage, such as + * - It can be written in `getTargetSeries` methods of data processors. + * - It can be written in `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. + * - The cache can be read in any stages. + * - "ec prepare" is not necessarily performed before each "ec full update" performing. */ export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { return ecModelCacheInner(ecModel).prepare; @@ -1305,11 +1307,66 @@ export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerE /** * The cache is auto cleared at the begining of a run of "ec full update". + * However, all shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. * * NOTICE: - * - Do not call it at "ec prepare" stage. See `getCachePerECPrepare` for details. - * - All shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. + * - The cache can only be written AFTER "ec prepare" stage (not included). + * See `getCachePerECPrepare` for details. */ export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { return ecModelCacheInner(ecModel).fullUpdate; } + +/** + * @usage + * - The earlier item takes precedence for duplicate items. + * - The input `arr` will be modified if `resolve` is null/undefined. + * - Callers can use `resolve` to manually modify the `currItem`. + * The input `arr` will not be modified if `resolve` is passed. + * `resolve` will be called on every item. + * - Callers need to handle null/undefined (if existing) in `getKey`. + */ +export function removeDuplicates( + arr: (TItem | NullUndefined)[], + getKey: (item: TItem) => string, + // `existingCount`: the count before this item is added. + resolve: ((item: TItem, existingCount: number) => void) | NullUndefined, +): void { + const dupMap = createHashMap(); + let writeIdx = 0; + each(arr, function (item) { + const key = getKey(item); + if (__DEV__) { + assert(isString(key)); + } + const count = dupMap.get(key) || 0; + if (resolve) { + resolve(item, count); + } + if (!count && !resolve) { + arr[writeIdx++] = item; + } + dupMap.set(key, count + 1); + }); + if (!resolve) { + arr.length = writeIdx; + } +} + +export function removeDuplicatesGetKeyFromValueProp( + item: {value: TValue} +): string { + if (__DEV__) { + assert(item.value != null); + } + return item.value + ''; +} + +export function removeDuplicatesGetKeyFromItemItself( + item: TValue +): string { + if (__DEV__) { + assert(item != null); + } + return item + ''; +} diff --git a/src/util/number.ts b/src/util/number.ts index accab98a08..13df66639e 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -804,7 +804,7 @@ export function getLeastCommonMultiple(a: number, b: number) { } /** - * NOTICE: Assume the input `val` is number or null/undefined, no type check. + * NOTICE: Assume the input `val` is number or null/undefined, no type check, no support of BitInt. * Therefore, it is NOT suitable for processing user input, but sufficient for * internal usage in most cases. * For platform-agnosticism, `Number.isFinite` is not used. diff --git a/src/util/types.ts b/src/util/types.ts index a04c6d3d25..5c87cce9b3 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -370,7 +370,7 @@ export interface StageHandler { */ overallReset?: StageHandlerOverallReset; /** - * Called only when this task in a pipeline, and "dirty". + * Called only when this task in a single pipeline, and "dirty". */ reset?: StageHandlerReset; } @@ -550,9 +550,11 @@ export type AxisLabelFormatterExtraBreakPart = { }; export interface ScaleTick { - value: number, - break?: VisualAxisBreak, - time?: TimeScaleTick['time'], + value: number; + break?: VisualAxisBreak; + time?: TimeScaleTick['time']; + // NOTICE: null/undefined mean it is unknown whether this tick is "nice". + notNice?: boolean | NullUndefined; }; export interface TimeScaleTick extends ScaleTick { time: { diff --git a/test/bar-overflow-time-plot.html b/test/bar-overflow-time-plot.html index 4ea53f1787..d5ebc4853f 100644 --- a/test/bar-overflow-time-plot.html +++ b/test/bar-overflow-time-plot.html @@ -201,6 +201,9 @@ }, inverse: _ctx.xAxisInverse, boundaryGap: _ctx.xAxisBoundaryGap, + splitArea: { + show: true, + }, }, yAxis: { axisTick: { diff --git a/test/ut/spec/util/model.test.ts b/test/ut/spec/util/model.test.ts index 40f7890324..e00cf22bda 100755 --- a/test/ut/spec/util/model.test.ts +++ b/test/ut/spec/util/model.test.ts @@ -18,7 +18,7 @@ * under the License. */ -import { compressBatches } from '@/src/util/model'; +import { compressBatches, removeDuplicates } from '@/src/util/model'; describe('util/model', function () { @@ -93,6 +93,167 @@ describe('util/model', function () { ]); }); + + describe('removeDuplicates', function () { + + type Item1 = { + name: string; + name2?: string; + extraNum?: number; + }; + type Item2 = { + value: number; + }; + + it('removeDuplicates_resolve1', function () { + const countRecord: number[] = []; + function resolve1(item: Item1, count: number): void { + countRecord.push(count); + item.name2 = item.name + ( + count > 0 ? (count - 1) : '' + ); + } + const arr: Item1[] = [ + {name: 'y'}, + {name: 'b'}, + {name: 'y'}, + {name: 't'}, + {name: 'y'}, + {name: 'z'}, + {name: 't'}, + ]; + const arrLengthOriginal = arr.length; + const arrNamesOriginal = arr.map(item => item.name); + removeDuplicates(arr, item => item.name, resolve1); + + expect(countRecord).toEqual([0, 0, 1, 0, 2, 0, 1]); + expect(arr.length).toEqual(arrLengthOriginal); + expect(arr.map(item => item.name)).toEqual(arrNamesOriginal); + expect(arr.map(item => item.name2)).toEqual(['y', 'b', 'y0', 't', 'y1', 'z', 't0']); + }); + + it('removeDuplicates_no_resolve_has_value', function () { + const arr: string[] = [ + 'y', + 'b', + 'y', + undefined, + 'y', + null, + 'y', + 't', + 'b', + ]; + removeDuplicates(arr, item => item + '', null); + expect(arr.length).toEqual(5); + expect(arr).toEqual(['y', 'b', undefined, null, 't']); + }); + + it('removeDuplicates_priority', function () { + const arr: Item1[] = [ + {name: 'y', extraNum: 100}, + {name: 'b', extraNum: 101}, + {name: 'y', extraNum: 102}, + {name: 't', extraNum: 103}, + {name: 'y', extraNum: 104}, + {name: 'z', extraNum: 105}, + {name: 't', extraNum: 106}, + ]; + removeDuplicates(arr, item => item.name, null); + expect(arr.length).toEqual(4); + expect(arr.map(item => item.name)).toEqual(['y', 'b', 't', 'z']); + expect(arr.map(item => item.extraNum)).toEqual([100, 101, 103, 105]); + }); + + it('removeDuplicates_edges_cases', function () { + function run(inputArr: Item2[], expectArr: Item2[]): void { + removeDuplicates(inputArr, (item: Item2) => item.value + '', null); + expect(inputArr).toEqual(expectArr); + } + + run( + [], + [] + ); + run( + [ + {value: 1}, + ], + [ + {value: 1} + ] + ); + run( + [ + { value: 1 }, + { value: 2 }, + { value: 3 } + ], + [ + { value: 1 }, + { value: 2 }, + { value: 3 } + ], + ); + run( + [ + { value: 1 }, + { value: 2 }, + { value: 2 }, + { value: 3 } + ], + [ + { value: 1 }, + { value: 2 }, + { value: 3 } + ], + ); + run( + [ + { value: 1 }, + { value: 1 }, + { value: 2 } + ], + [ + { value: 1 }, + { value: 2 } + ], + ); + run( + [ + { value: 1 }, + { value: 2 }, + { value: 2 } + ], + [ + { value: 1 }, + { value: 2 } + ], + ); + run( + [ + { value: 2 }, + { value: 2 }, + { value: 2 } + ], + [ + { value: 2 } + ], + ); + run( + [ + { value: 5 }, + { value: 5 } + ], + [ + { value: 5 }, + ], + ); + + }); + + }); + }); }); \ No newline at end of file From 7a8d38bae90dce8af628bab58f6c186125876e89 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 27 Feb 2026 22:31:35 +0800 Subject: [PATCH 20/31] fix: Fix inappropriate impl introduced by the previous commits. --- src/chart/custom/CustomView.ts | 4 ++-- src/component/axis/AxisBuilder.ts | 4 ++-- src/component/toolbox/feature/DataView.ts | 2 +- src/coord/Axis.ts | 2 +- src/coord/axisBand.ts | 20 +++++++++++++------- src/coord/axisHelper.ts | 3 +-- src/layout/barGrid.ts | 18 ++++++++++++++---- src/model/Series.ts | 1 - 8 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/chart/custom/CustomView.ts b/src/chart/custom/CustomView.ts index 066d24b9ef..fe50f12ce1 100644 --- a/src/chart/custom/CustomView.ts +++ b/src/chart/custom/CustomView.ts @@ -27,7 +27,7 @@ import * as labelStyleHelper from '../../label/labelStyle'; import {getDefaultLabel} from '../helper/labelHelper'; import { BarGridLayoutOptionForCustomSeries, BarGridLayoutResultForCustomSeries, - getLayoutOnAxis + computeBarLayoutForCustomSeries } from '../../layout/barGrid'; import DataDiffer from '../../data/DataDiffer'; import Model from '../../model/Model'; @@ -924,7 +924,7 @@ function makeRenderItem( ): BarGridLayoutResultForCustomSeries { if (coordSys.type === 'cartesian2d') { const baseAxis = coordSys.getBaseAxis() as Axis2D; - return getLayoutOnAxis(defaults({axis: baseAxis}, opt)); + return computeBarLayoutForCustomSeries(defaults({axis: baseAxis}, opt)); } } diff --git a/src/component/axis/AxisBuilder.ts b/src/component/axis/AxisBuilder.ts index 78fc96ea66..40783f0672 100644 --- a/src/component/axis/AxisBuilder.ts +++ b/src/component/axis/AxisBuilder.ts @@ -1148,7 +1148,7 @@ function syncLabelIgnoreToMajorTicks( tickEls: graphic.Line[], ) { if (cfg.showMinorTicks) { - // It probably unreaasonable to hide major ticks when show minor ticks. + // It probably unreasonable to hide major ticks when show minor ticks. return; } each(labelLayoutList, labelLayout => { @@ -1481,8 +1481,8 @@ function buildAxisLabel( eventData.value = rawLabel; eventData.tickIndex = index; const labelItemTickBreak = labelItem.tick.break; - const labelItemTickBreakParsedBreak = labelItemTickBreak.parsedBreak; if (labelItemTickBreak) { + const labelItemTickBreakParsedBreak = labelItemTickBreak.parsedBreak; eventData.break = { // type: labelItem.break.type, start: labelItemTickBreakParsedBreak.vmin, diff --git a/src/component/toolbox/feature/DataView.ts b/src/component/toolbox/feature/DataView.ts index 391699197f..def839af52 100644 --- a/src/component/toolbox/feature/DataView.ts +++ b/src/component/toolbox/feature/DataView.ts @@ -75,7 +75,7 @@ function groupSeries(ecModel: GlobalModel) { const coordSys = seriesModel.coordinateSystem; if (coordSys && (coordSys.type === 'cartesian2d' || coordSys.type === 'polar')) { - // TODO: TYPE Consider polar? Include polar may increase unecessary bundle size. + // TODO: TYPE Consider polar? Include polar may increase unnecessary bundle size. const baseAxis = (coordSys as Cartesian2D).getBaseAxis(); if (baseAxis.type === 'category') { const key = getCartesianAxisHashKey(baseAxis); diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index b0370028fd..ce36ee0051 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -245,7 +245,7 @@ class Axis { * NOTICE: Can only be called after `adoptBandWidth` being called in `CoordinateSystem#update` stage. */ getBandWidth(): number { - calcBandWidth(tmpOutBandWidth, this); + calcBandWidth(tmpOutBandWidth, this, true); // NOTICE: Do not add logic here. Implement everthing in `calcBandWidth`. return tmpOutBandWidth.bandWidth; } diff --git a/src/coord/axisBand.ts b/src/coord/axisBand.ts index 95edb208f1..b90abc2ed9 100644 --- a/src/coord/axisBand.ts +++ b/src/coord/axisBand.ts @@ -31,7 +31,8 @@ import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; const SINGULAR_BAND_WIDTH_RATIO = 0.7; export type AxisBandWidthResult = { - // In px. May be NaN/null/undefined if no meaningfull bandWidth. + // In px. After the calling of `calcBandWidth`, it may be NaN if no meaningfull bandWidth, + // but never be null/undefined any more. bandWidth?: number | NullUndefined; kind?: AxisBandWidthKind; // If `AXIS_BAND_WIDTH_KIND_NORMAL`, this is a ratio from px span to data span, exists only if not singular. @@ -66,15 +67,22 @@ export const AXIS_BAND_WIDTH_KIND_NORMAL = 2; */ export function calcBandWidth( out: AxisBandWidthResult, - axis: Axis + axis: Axis, + useFallback: boolean ): void { // Clear out. - out.bandWidth = out.ratio = out.kind = undefined; + out.ratio = out.kind = undefined; + out.bandWidth = NaN; const scale = axis.scale; if (isOrdinalScale(scale) - || !calcBandWidthForNumericAxisIfPossible(out, axis, scale) + || ( + !calcBandWidthForNumericAxisIfPossible(out, axis, scale) + // The fallback is only reasonable in several special cases (e.g., axis number is interger). + // So it is used only for backward compatibility. + && useFallback + ) ) { calcBandWidthForCategoryAxisOrFallback(out, axis, scale); } @@ -98,9 +106,7 @@ function calcBandWidthForCategoryAxisOrFallback( // Fix #2728, avoid NaN when only one data. len === 0 && (len = 1); - const size = Math.abs(axisExtent[1] - axisExtent[0]); - - out.bandWidth = Math.abs(size) / len; + out.bandWidth = mathAbs(axisExtent[1] - axisExtent[0]) / len; } function calcBandWidthForNumericAxisIfPossible( diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 8739c5ebff..4553389647 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -38,7 +38,6 @@ import { AxisLabelFormatterExtraParams, OptionAxisType, AXIS_TYPES, - AxisShowMinMaxLabelOption, } from './axisCommonTypes'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; @@ -52,8 +51,8 @@ import { } from '../scale/helper'; import { AxisModelExtendedInCreator } from './axisModelCreator'; import { initExtentForUnion, makeInner } from '../util/model'; -import { ComponentModel } from '../echarts.simple'; import { SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_OUT_OF_BREAK } from '../scale/scaleMapper'; +import ComponentModel from '../model/Component'; const axisInner = makeInner<{ diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index 6d1616fc13..9cd2c44b30 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -111,12 +111,22 @@ type BarGridLayoutResultItem = BarGridLayoutResultItemInternal & { export type BarGridLayoutResultForCustomSeries = BarGridLayoutResultItem[] | NullUndefined; /** - * @return If axis.type is not 'category', return undefined. + * Return null/undefined if not 'category' axis. + * + * PENDING: The layout on non-'category' axis relies on `bandWidth`, which is calculated + * based on the `linearPositiveMinGap` of series data. This strategy is somewhat heuristic + * and will not be public to custom series until required in future. Additionally, more ec + * options may be introduced for that, because it requires `requireAxisStatistics` to be + * called on custom series that requires this feature. */ -export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultForCustomSeries { +export function computeBarLayoutForCustomSeries(opt: BarGridLayoutOption): BarGridLayoutResultForCustomSeries { + if (!isOrdinalScale(opt.axis.scale)) { + return; + } + const params: BarGridLayoutAxisSeriesInfo[] = []; const bandWidthResult: AxisBandWidthResult = {}; - calcBandWidth(bandWidthResult, opt.axis); + calcBandWidth(bandWidthResult, opt.axis, false); for (let i = 0; i < opt.count || 0; i++) { params.push(defaults({ @@ -162,7 +172,7 @@ function createLayoutInfoListOnAxis( const seriesInfoOnAxis: BarGridLayoutAxisSeriesInfo[] = []; const bandWidthResult: AxisBandWidthResult = {}; - calcBandWidth(bandWidthResult, baseAxis); + calcBandWidth(bandWidthResult, baseAxis, false); const bandWidth = bandWidthResult.bandWidth; eachCollectedSeries(baseAxis, axisStatKey(seriesType), function (seriesModel: BaseBarSeriesModel) { diff --git a/src/model/Series.ts b/src/model/Series.ts index f4fe5fb8da..5446cdccdf 100644 --- a/src/model/Series.ts +++ b/src/model/Series.ts @@ -434,7 +434,6 @@ class SeriesModel extends ComponentMode */ getBaseAxis(): Axis { const coordSys = this.coordinateSystem; - // @ts-ignore return coordSys && coordSys.getBaseAxis && coordSys.getBaseAxis(); } From dbfaf6a73728394d541127adb24391d9e2f33ee9 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 6 Mar 2026 18:17:26 +0800 Subject: [PATCH 21/31] fix&feature: (1) feature: Add option boxplot.clip (2) feature: Enable boxplot and candlestick containShape for "value"/"time"/"log" axis, enable proper shadow axisPointer. (candlestick-case.html, boxplot-multi.html) (3) fix: Fix candlestick ends shapes can not be displayed on "value"/"time" axis. (candlestick-case.html) (4) fix: candlestick shape width is inappropriate when datazoom filterMode is 'none'/'empty'. (candlestick-case.html) (5) fix: In polar coordinate system, support "value"/"time" axis as angle/radius axis, enable propere bandWidth for bar series, enable proper shadow axisPointer. (bar-polar-multi-series-radial.html, bar-polar-multi-series.html) --- src/chart/bar/BarView.ts | 1 + src/chart/boxplot/BoxplotSeries.ts | 6 +- src/chart/boxplot/BoxplotView.ts | 43 +- src/chart/boxplot/boxplotLayout.ts | 134 +++-- src/chart/boxplot/install.ts | 4 +- src/chart/candlestick/CandlestickSeries.ts | 4 +- src/chart/candlestick/CandlestickView.ts | 56 +- src/chart/candlestick/candlestickLayout.ts | 55 +- src/chart/candlestick/candlestickVisual.ts | 4 +- src/chart/candlestick/install.ts | 4 +- src/chart/candlestick/preprocessor.ts | 3 +- src/chart/heatmap/HeatmapView.ts | 5 +- src/chart/helper/axisSnippets.ts | 62 +++ .../helper/createClipPathFromCoordSys.ts | 40 +- src/chart/helper/whiskerBoxCommon.ts | 36 +- src/component/axisPointer/BaseAxisPointer.ts | 5 +- .../axisPointer/CartesianAxisPointer.ts | 40 +- src/component/axisPointer/PolarAxisPointer.ts | 73 ++- .../axisPointer/SingleAxisPointer.ts | 35 +- src/component/axisPointer/viewHelper.ts | 37 ++ src/component/dataZoom/AxisProxy.ts | 10 +- src/component/polar/install.ts | 10 +- src/component/tooltip/TooltipView.ts | 2 +- src/coord/Axis.ts | 12 +- src/coord/CoordinateSystem.ts | 4 + src/coord/axisBand.ts | 185 ++++--- src/coord/axisCommonTypes.ts | 17 +- src/coord/axisHelper.ts | 49 +- src/coord/axisStatistics.ts | 448 +++++++++++----- src/coord/cartesian/Cartesian2D.ts | 4 +- src/coord/cartesian/Grid.ts | 37 +- src/coord/cartesian/GridModel.ts | 2 + src/coord/cartesian/prepareCustom.ts | 3 +- src/coord/polar/Polar.ts | 5 +- src/coord/polar/PolarModel.ts | 4 +- src/coord/polar/prepareCustom.ts | 3 +- src/coord/radar/RadarModel.ts | 4 + src/coord/scaleRawExtentInfo.ts | 139 ++--- src/coord/single/prepareCustom.ts | 3 +- src/core/CoordinateSystem.ts | 8 - src/core/echarts.ts | 3 + src/data/DataStore.ts | 12 +- src/data/SeriesDimensionDefine.ts | 4 +- src/data/helper/dataStackHelper.ts | 2 +- src/layout/barCommon.ts | 65 +++ src/layout/barGrid.ts | 147 +++--- src/layout/barPolar.ts | 457 ++++++++-------- src/scale/Log.ts | 14 +- src/scale/scaleMapper.ts | 16 +- src/util/jitter.ts | 10 +- src/util/model.ts | 32 +- src/util/number.ts | 2 +- src/util/types.ts | 1 + src/util/vendor.ts | 122 ++++- test/bar-overflow-plot2.html | 23 + test/bar-overflow-time-plot.html | 27 +- test/bar-polar-multi-series-radial.html | 168 +++++- test/bar-polar-multi-series.html | 136 ++++- test/boxplot-multi.html | 237 +++++++-- test/build/mktest-tpl.html | 2 +- test/candlestick-case.html | 489 ++++++++++++++---- test/runTest/actions/__meta__.json | 4 +- .../actions/bar-polar-multi-series.json | 2 +- test/runTest/actions/candlestick-case.json | 2 +- 64 files changed, 2522 insertions(+), 1051 deletions(-) create mode 100644 src/chart/helper/axisSnippets.ts create mode 100644 src/layout/barCommon.ts diff --git a/src/chart/bar/BarView.ts b/src/chart/bar/BarView.ts index 5c0e4bca9d..ab6337d59b 100644 --- a/src/chart/bar/BarView.ts +++ b/src/chart/bar/BarView.ts @@ -99,6 +99,7 @@ function getClipArea(coord: CoordSysOfBar, data: SeriesData) { // When boundaryGap is false in category axis, bar may exceed the grid. // We should not clip this part. // See test/bar2.html + // PENDING: The effect is not preferable, but we preserve it for backward compatibility. if (baseAxis.type === 'category' && !baseAxis.onBand) { const expandWidth = data.getLayout('bandWidth'); if (baseAxis.isHorizontal()) { diff --git a/src/chart/boxplot/BoxplotSeries.ts b/src/chart/boxplot/BoxplotSeries.ts index 7fba2a79fb..f839ca552a 100644 --- a/src/chart/boxplot/BoxplotSeries.ts +++ b/src/chart/boxplot/BoxplotSeries.ts @@ -65,6 +65,7 @@ export interface BoxplotSeriesOption coordinateSystem?: 'cartesian2d' layout?: LayoutOrient + clip?: boolean; /** * [min, max] can be percent of band width. */ @@ -73,9 +74,11 @@ export interface BoxplotSeriesOption data?: (BoxplotDataValue | BoxplotDataItemOption)[] } +export const SERIES_TYPE_BOXPLOT = 'boxplot'; + class BoxplotSeriesModel extends SeriesModel { - static readonly type = 'series.boxplot'; + static readonly type = 'series.' + SERIES_TYPE_BOXPLOT; readonly type = BoxplotSeriesModel.type; static readonly dependencies = ['xAxis', 'yAxis', 'grid']; @@ -109,6 +112,7 @@ class BoxplotSeriesModel extends SeriesModel { legendHoverLink: true, layout: null, + clip: true, boxWidth: [7, 50], itemStyle: { diff --git a/src/chart/boxplot/BoxplotView.ts b/src/chart/boxplot/BoxplotView.ts index 0d88309773..faffc27451 100644 --- a/src/chart/boxplot/BoxplotView.ts +++ b/src/chart/boxplot/BoxplotView.ts @@ -17,20 +17,27 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import ChartView from '../../view/Chart'; import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import Path, { PathProps } from 'zrender/src/graphic/Path'; -import BoxplotSeriesModel, { BoxplotDataItemOption } from './BoxplotSeries'; +import BoxplotSeriesModel, { SERIES_TYPE_BOXPLOT, BoxplotDataItemOption } from './BoxplotSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import SeriesData from '../../data/SeriesData'; import { BoxplotItemLayout } from './boxplotLayout'; import { saveOldStyle } from '../../animation/basicTransition'; +import { resolveNormalBoxClipping } from '../helper/whiskerBoxCommon'; +import { + createClipPath, SHAPE_CLIP_KIND_FULLY_CLIPPED, SHAPE_CLIP_KIND_NOT_CLIPPED, + SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, + updateClipPath +} from '../helper/createClipPathFromCoordSys'; +import { map } from 'zrender/src/core/util'; + class BoxplotView extends ChartView { - static type = 'boxplot'; + static type = SERIES_TYPE_BOXPLOT; type = BoxplotView.type; private _data: SeriesData; @@ -47,12 +54,29 @@ class BoxplotView extends ChartView { } const constDim = seriesModel.getWhiskerBoxesLayout() === 'horizontal' ? 1 : 0; + const needClip = seriesModel.get('clip', true); + const coordSys = seriesModel.coordinateSystem; + const clipArea = coordSys.getArea && coordSys.getArea(); + const clipPath = needClip && createClipPath(coordSys, false, seriesModel); data.diff(oldData) .add(function (newIdx) { if (data.hasValue(newIdx)) { const itemLayout = data.getItemLayout(newIdx) as BoxplotItemLayout; + + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { + return; + } + const symbolEl = createNormalBox(itemLayout, data, newIdx, constDim, true); + // One axis tick can corresponds to a group of box items (from different series), + // so it may be visually misleading when a group of items are partially outside + // but no clipping is applied. + // Consider performance of zr Element['clipPath'], only set to partially clipped elements. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, symbolEl, clipPath); + data.setItemGraphicEl(newIdx, symbolEl); group.add(symbolEl); } @@ -67,6 +91,14 @@ class BoxplotView extends ChartView { } const itemLayout = data.getItemLayout(newIdx) as BoxplotItemLayout; + + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { + group.remove(symbolEl); + return; + } + if (!symbolEl) { symbolEl = createNormalBox(itemLayout, data, newIdx, constDim); } @@ -75,6 +107,9 @@ class BoxplotView extends ChartView { updateNormalBoxData(itemLayout, symbolEl, data, newIdx); } + // See `updateClipPath` in `add`. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, symbolEl, clipPath); + group.add(symbolEl); data.setItemGraphicEl(newIdx, symbolEl); @@ -192,7 +227,7 @@ function updateNormalBoxData( } function transInit(points: number[][], dim: number, itemLayout: BoxplotItemLayout) { - return zrUtil.map(points, function (point) { + return map(points, function (point) { point = point.slice(); point[dim] = itemLayout.initBaseline; return point; diff --git a/src/chart/boxplot/boxplotLayout.ts b/src/chart/boxplot/boxplotLayout.ts index 053462d2f2..feda1efd55 100644 --- a/src/chart/boxplot/boxplotLayout.ts +++ b/src/chart/boxplot/boxplotLayout.ts @@ -17,103 +17,69 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; +import { isArray } from 'zrender/src/core/util'; import {parsePercent} from '../../util/number'; import type GlobalModel from '../../model/Global'; -import BoxplotSeriesModel from './BoxplotSeries'; -import Axis2D from '../../coord/cartesian/Axis2D'; - -const each = zrUtil.each; - -interface GroupItem { - seriesModels: BoxplotSeriesModel[] - axis: Axis2D - boxOffsetList: number[] - boxWidthList: number[] -} +import BoxplotSeriesModel, { SERIES_TYPE_BOXPLOT } from './BoxplotSeries'; +import { + eachCollectedAxis, eachCollectedSeries, getCollectedSeriesLength, + requireAxisStatistics +} from '../../coord/axisStatistics'; +import { makeCallOnlyOnce } from '../../util/model'; +import { EChartsExtensionInstallRegisters } from '../../extension'; +import Axis from '../../coord/Axis'; +import { registerAxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; +import { calcBandWidth } from '../../coord/axisBand'; +import { + makeAxisStatKey, + createSimpleAxisStatClient, createBandWidthBasedAxisContainShapeHandler +} from '../helper/axisSnippets'; + + +const callOnlyOnce = makeCallOnlyOnce(); export interface BoxplotItemLayout { ends: number[][] initBaseline: number } -export default function boxplotLayout(ecModel: GlobalModel) { - - const groupResult = groupSeriesByAxis(ecModel); - - each(groupResult, function (groupItem) { - const seriesModels = groupItem.seriesModels; - - if (!seriesModels.length) { +export function boxplotLayout(ecModel: GlobalModel) { + const axisStatKey = makeAxisStatKey(SERIES_TYPE_BOXPLOT); + eachCollectedAxis(ecModel, axisStatKey, function (axis) { + const seriesCount = getCollectedSeriesLength(axis, axisStatKey); + if (!seriesCount) { return; } - - calculateBase(groupItem); - - each(seriesModels, function (seriesModel, idx) { + const baseResult = calculateBase(axis, seriesCount); + eachCollectedSeries(axis, axisStatKey, function (seriesModel: BoxplotSeriesModel, idx) { layoutSingleSeries( seriesModel, - groupItem.boxOffsetList[idx], - groupItem.boxWidthList[idx] + baseResult.boxOffsetList[idx], + baseResult.boxWidthList[idx] ); }); }); } -/** - * Group series by axis. - */ -function groupSeriesByAxis(ecModel: GlobalModel) { - const result: GroupItem[] = []; - const axisList: Axis2D[] = []; - - ecModel.eachSeriesByType('boxplot', function (seriesModel: BoxplotSeriesModel) { - const baseAxis = seriesModel.getBaseAxis(); - let idx = zrUtil.indexOf(axisList, baseAxis); - - if (idx < 0) { - idx = axisList.length; - axisList[idx] = baseAxis; - result[idx] = { - axis: baseAxis, - seriesModels: [] - } as GroupItem; - } - - result[idx].seriesModels.push(seriesModel); - }); - - return result; -} - /** * Calculate offset and box width for each series. */ -function calculateBase(groupItem: GroupItem) { - const baseAxis = groupItem.axis; - const seriesModels = groupItem.seriesModels; - const seriesCount = seriesModels.length; - - const boxWidthList: number[] = groupItem.boxWidthList = []; - const boxOffsetList: number[] = groupItem.boxOffsetList = []; +function calculateBase(baseAxis: Axis, seriesCount: number): { + boxOffsetList: number[]; + boxWidthList: number[]; +} { + const boxWidthList: number[] = []; + const boxOffsetList: number[] = []; const boundList: number[][] = []; - let bandWidth: number; - if (baseAxis.type === 'category') { - bandWidth = baseAxis.getBandWidth(); - } - else { - let maxDataCount = 0; - each(seriesModels, function (seriesModel) { - maxDataCount = Math.max(maxDataCount, seriesModel.getData().count()); - }); - const extent = baseAxis.getExtent(); - bandWidth = Math.abs(extent[1] - extent[0]) / maxDataCount; - } + const bandWidth = calcBandWidth( + baseAxis, + {fromStat: {key: makeAxisStatKey(SERIES_TYPE_BOXPLOT)}, min: 1}, + ).w; - each(seriesModels, function (seriesModel) { + eachCollectedSeries(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel: BoxplotSeriesModel) { let boxWidthBound = seriesModel.get('boxWidth'); - if (!zrUtil.isArray(boxWidthBound)) { + if (!isArray(boxWidthBound)) { boxWidthBound = [boxWidthBound, boxWidthBound]; } boundList.push([ @@ -127,7 +93,7 @@ function calculateBase(groupItem: GroupItem) { const boxWidth = (availableWidth - boxGap * (seriesCount - 1)) / seriesCount; let base = boxWidth / 2 - availableWidth / 2; - each(seriesModels, function (seriesModel, idx) { + eachCollectedSeries(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel, idx) { boxOffsetList.push(base); base += boxGap + boxWidth; @@ -135,6 +101,11 @@ function calculateBase(groupItem: GroupItem) { Math.min(Math.max(boxWidth, boundList[idx][0]), boundList[idx][1]) ); }); + + return { + boxOffsetList, + boxWidthList, + }; } /** @@ -212,3 +183,18 @@ function layoutSingleSeries(seriesModel: BoxplotSeriesModel, offset: number, box ends.push(from, to); } } + +export function registerBoxplotAxisHandlers(registers: EChartsExtensionInstallRegisters) { + callOnlyOnce(registers, function () { + const axisStatKey = makeAxisStatKey(SERIES_TYPE_BOXPLOT); + requireAxisStatistics( + registers, + axisStatKey, + createSimpleAxisStatClient(SERIES_TYPE_BOXPLOT) + ); + registerAxisContainShapeHandler( + axisStatKey, + createBandWidthBasedAxisContainShapeHandler(axisStatKey) + ); + }); +} diff --git a/src/chart/boxplot/install.ts b/src/chart/boxplot/install.ts index b5a7efcd98..0cfebd0a19 100644 --- a/src/chart/boxplot/install.ts +++ b/src/chart/boxplot/install.ts @@ -20,7 +20,7 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import BoxplotSeriesModel from './BoxplotSeries'; import BoxplotView from './BoxplotView'; -import boxplotLayout from './boxplotLayout'; +import {boxplotLayout, registerBoxplotAxisHandlers} from './boxplotLayout'; import { boxplotTransform } from './boxplotTransform'; export function install(registers: EChartsExtensionInstallRegisters) { @@ -28,4 +28,6 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerChartView(BoxplotView); registers.registerLayout(boxplotLayout); registers.registerTransform(boxplotTransform); + + registerBoxplotAxisHandlers(registers); } diff --git a/src/chart/candlestick/CandlestickSeries.ts b/src/chart/candlestick/CandlestickSeries.ts index 55938e3b48..d4196d121e 100644 --- a/src/chart/candlestick/CandlestickSeries.ts +++ b/src/chart/candlestick/CandlestickSeries.ts @@ -82,9 +82,11 @@ export interface CandlestickSeriesOption data?: (CandlestickDataValue | CandlestickDataItemOption)[] } +export const SERIES_TYPE_CANDLESTICK = 'candlestick'; + class CandlestickSeriesModel extends SeriesModel { - static readonly type = 'series.candlestick'; + static readonly type = 'series.' + SERIES_TYPE_CANDLESTICK; readonly type = CandlestickSeriesModel.type; static readonly dependencies = ['xAxis', 'yAxis', 'grid']; diff --git a/src/chart/candlestick/CandlestickView.ts b/src/chart/candlestick/CandlestickView.ts index 706cae6fcc..d2acdba758 100644 --- a/src/chart/candlestick/CandlestickView.ts +++ b/src/chart/candlestick/CandlestickView.ts @@ -22,24 +22,27 @@ import ChartView from '../../view/Chart'; import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import Path, { PathProps } from 'zrender/src/graphic/Path'; -import {createClipPath} from '../helper/createClipPathFromCoordSys'; -import CandlestickSeriesModel, { CandlestickDataItemOption } from './CandlestickSeries'; +import { + createClipPath, SHAPE_CLIP_KIND_FULLY_CLIPPED, SHAPE_CLIP_KIND_NOT_CLIPPED, + SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, updateClipPath +} from '../helper/createClipPathFromCoordSys'; +import CandlestickSeriesModel, { SERIES_TYPE_CANDLESTICK, CandlestickDataItemOption } from './CandlestickSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { StageHandlerProgressParams } from '../../util/types'; import SeriesData from '../../data/SeriesData'; import {CandlestickItemLayout} from './candlestickLayout'; -import { CoordinateSystemClipArea } from '../../coord/CoordinateSystem'; import Model from '../../model/Model'; import { saveOldStyle } from '../../animation/basicTransition'; import Element from 'zrender/src/Element'; import { getBorderColor, getColor } from './candlestickVisual'; +import { resolveNormalBoxClipping } from '../helper/whiskerBoxCommon'; const SKIP_PROPS = ['color', 'borderColor'] as const; class CandlestickView extends ChartView { - static readonly type = 'candlestick'; + static readonly type = SERIES_TYPE_CANDLESTICK; readonly type = CandlestickView.type; private _isLargeDraw: boolean; @@ -96,9 +99,10 @@ class CandlestickView extends ChartView { const group = this.group; const isSimpleBox = data.getLayout('isSimpleBox'); - const needsClip = seriesModel.get('clip', true); - const coord = seriesModel.coordinateSystem; - const clipArea = coord.getArea && coord.getArea(); + const needClip = seriesModel.get('clip', true); + const coordSys = seriesModel.coordinateSystem; + const clipArea = coordSys.getArea && coordSys.getArea(); + const clipPath = needClip && createClipPath(coordSys, false, seriesModel); // There is no old data only when first rendering or switching from // stream mode to normal mode, where previous elements should be removed. @@ -113,13 +117,20 @@ class CandlestickView extends ChartView { if (data.hasValue(newIdx)) { const itemLayout = data.getItemLayout(newIdx) as CandlestickItemLayout; - if (needsClip && isNormalBoxClipped(clipArea, itemLayout)) { + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { return; } const el = createNormalBox(itemLayout, newIdx, transPointDim, true); graphic.initProps(el, {shape: {points: itemLayout.ends}}, seriesModel, newIdx); + // In some edge cases (e.g., single item with min/max set), the disappearance of + // items may confuse users if no clipping is applied. + // Consider performance of zr Element['clipPath'], only set to partially clipped elements. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, el, clipPath); + setBoxCommon(el, data, newIdx, isSimpleBox); group.add(el); @@ -137,7 +148,10 @@ class CandlestickView extends ChartView { } const itemLayout = data.getItemLayout(newIdx) as CandlestickItemLayout; - if (needsClip && isNormalBoxClipped(clipArea, itemLayout)) { + + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { group.remove(el); return; } @@ -157,6 +171,9 @@ class CandlestickView extends ChartView { setBoxCommon(el, data, newIdx, isSimpleBox); + // See `updateClipPath` in `add`. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, el, clipPath); + group.add(el); data.setItemGraphicEl(newIdx, el); }) @@ -177,13 +194,7 @@ class CandlestickView extends ChartView { const clipPath = seriesModel.get('clip', true) ? createClipPath(seriesModel.coordinateSystem, false, seriesModel) : null; - if (clipPath) { - this.group.setClipPath(clipPath); - } - else { - this.group.removeClipPath(); - } - + updateClipPath(!!clipPath, this.group, clipPath); } _incrementalRenderNormal(params: StageHandlerProgressParams, seriesModel: CandlestickSeriesModel) { @@ -215,6 +226,7 @@ class CandlestickView extends ChartView { _clear() { this.group.removeAll(); + updateClipPath(false, this.group, null); this._data = null; } } @@ -283,18 +295,6 @@ function createNormalBox( }); } -function isNormalBoxClipped(clipArea: CoordinateSystemClipArea, itemLayout: CandlestickItemLayout) { - let clipped = true; - for (let i = 0; i < itemLayout.ends.length; i++) { - // If any point are in the region. - if (clipArea.contain(itemLayout.ends[i][0], itemLayout.ends[i][1])) { - clipped = false; - break; - } - } - return clipped; -} - function setBoxCommon(el: NormalBoxPath, data: SeriesData, dataIndex: number, isSimpleBox?: boolean) { const itemModel = data.getItemModel(dataIndex) as Model; diff --git a/src/chart/candlestick/candlestickLayout.ts b/src/chart/candlestick/candlestickLayout.ts index 6b6fa03a6e..5abd593abe 100644 --- a/src/chart/candlestick/candlestickLayout.ts +++ b/src/chart/candlestick/candlestickLayout.ts @@ -19,14 +19,27 @@ import {subPixelOptimize} from '../../util/graphic'; import createRenderPlanner from '../helper/createRenderPlanner'; -import {parsePercent} from '../../util/number'; +import {mathMax, mathMin, parsePercent} from '../../util/number'; import {map, retrieve2} from 'zrender/src/core/util'; import { DimensionIndex, StageHandler, StageHandlerProgressParams } from '../../util/types'; -import CandlestickSeriesModel, { CandlestickDataItemOption } from './CandlestickSeries'; +import CandlestickSeriesModel, { SERIES_TYPE_CANDLESTICK, CandlestickDataItemOption } from './CandlestickSeries'; import SeriesData from '../../data/SeriesData'; import { RectLike } from 'zrender/src/core/BoundingRect'; import DataStore from '../../data/DataStore'; import { createFloat32Array } from '../../util/vendor'; +import { makeCallOnlyOnce } from '../../util/model'; +import { + requireAxisStatistics +} from '../../coord/axisStatistics'; +import { EChartsExtensionInstallRegisters } from '../../extension'; +import { registerAxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; +import { + makeAxisStatKey, createSimpleAxisStatClient, createBandWidthBasedAxisContainShapeHandler +} from '../helper/axisSnippets'; +import { calcBandWidth } from '../../coord/axisBand'; + + +const callOnlyOnce = makeCallOnlyOnce(); export interface CandlestickItemLayout { sign: number @@ -40,9 +53,9 @@ export interface CandlestickLayoutMeta { isSimpleBox: boolean } -const candlestickLayout: StageHandler = { +export const candlestickLayout: StageHandler = { - seriesType: 'candlestick', + seriesType: SERIES_TYPE_CANDLESTICK, plan: createRenderPlanner(), @@ -87,8 +100,8 @@ const candlestickLayout: StageHandler = { const lowestVal = store.get(lowestDimI, dataIndex) as number; const highestVal = store.get(highestDimI, dataIndex) as number; - const ocLow = Math.min(openVal, closeVal); - const ocHigh = Math.max(openVal, closeVal); + const ocLow = mathMin(openVal, closeVal); + const ocHigh = mathMax(openVal, closeVal); const ocLowPoint = getPoint(ocLow, axisDimVal); const ocHighPoint = getPoint(ocHigh, axisDimVal); @@ -229,7 +242,7 @@ function getSign( ? 0 : (dataIndex > 0 // If close === open, compare with close of last record - ? (store.get(closeDimI, dataIndex - 1) <= closeVal ? 1 : -1) + ? ((store.get(closeDimI, dataIndex - 1) as number) <= closeVal ? 1 : -1) // No record of previous, set to be positive : 1 ); @@ -240,14 +253,11 @@ function getSign( function calculateCandleWidth(seriesModel: CandlestickSeriesModel, data: SeriesData) { const baseAxis = seriesModel.getBaseAxis(); - let extent; - const bandWidth = baseAxis.type === 'category' - ? baseAxis.getBandWidth() - : ( - extent = baseAxis.getExtent(), - Math.abs(extent[1] - extent[0]) / data.count() - ); + const bandWidth = calcBandWidth( + baseAxis, + {fromStat: {key: makeAxisStatKey(SERIES_TYPE_CANDLESTICK)}, min: 1} + ).w; const barMaxWidth = parsePercent( retrieve2(seriesModel.get('barMaxWidth'), bandWidth), @@ -262,7 +272,20 @@ function calculateCandleWidth(seriesModel: CandlestickSeriesModel, data: SeriesD return barWidth != null ? parsePercent(barWidth, bandWidth) // Put max outer to ensure bar visible in spite of overlap. - : Math.max(Math.min(bandWidth / 2, barMaxWidth), barMinWidth); + : mathMax(mathMin(bandWidth / 2, barMaxWidth), barMinWidth); } -export default candlestickLayout; +export function registerCandlestickAxisHandlers(registers: EChartsExtensionInstallRegisters) { + callOnlyOnce(registers, function () { + const axisStatKey = makeAxisStatKey(SERIES_TYPE_CANDLESTICK); + requireAxisStatistics( + registers, + axisStatKey, + createSimpleAxisStatClient(SERIES_TYPE_CANDLESTICK) + ); + registerAxisContainShapeHandler( + axisStatKey, + createBandWidthBasedAxisContainShapeHandler(axisStatKey) + ); + }); +} diff --git a/src/chart/candlestick/candlestickVisual.ts b/src/chart/candlestick/candlestickVisual.ts index a665ea3b69..6a3cb42a4d 100644 --- a/src/chart/candlestick/candlestickVisual.ts +++ b/src/chart/candlestick/candlestickVisual.ts @@ -19,7 +19,7 @@ import createRenderPlanner from '../helper/createRenderPlanner'; import { StageHandler } from '../../util/types'; -import CandlestickSeriesModel, { CandlestickDataItemOption } from './CandlestickSeries'; +import CandlestickSeriesModel, { SERIES_TYPE_CANDLESTICK, CandlestickDataItemOption } from './CandlestickSeries'; import Model from '../../model/Model'; import { extend } from 'zrender/src/core/util'; @@ -46,7 +46,7 @@ export function getBorderColor(sign: number, model: Model; -function createGridClipPath( +export function createGridClipPath( cartesian: Cartesian2D, hasAnimation: boolean, seriesModel: SeriesModelWithLineWidth, @@ -104,7 +106,7 @@ function createGridClipPath( return clipPath; } -function createPolarClipPath( +export function createPolarClipPath( polar: Polar, hasAnimation: boolean, seriesModel: SeriesModelWithLineWidth @@ -146,7 +148,7 @@ function createPolarClipPath( return clipPath; } -function createClipPath( +export function createClipPath( coordSys: CoordinateSystem, hasAnimation: boolean, seriesModel: SeriesModelWithLineWidth, @@ -165,8 +167,26 @@ function createClipPath( return null; } -export { - createGridClipPath, - createPolarClipPath, - createClipPath -}; +export type ShapeClipKind = + typeof SHAPE_CLIP_KIND_NOT_CLIPPED + | typeof SHAPE_CLIP_KIND_PARTIALLY_CLIPPED + | typeof SHAPE_CLIP_KIND_FULLY_CLIPPED; +export const SHAPE_CLIP_KIND_NOT_CLIPPED = 0; +export const SHAPE_CLIP_KIND_PARTIALLY_CLIPPED = 1; +export const SHAPE_CLIP_KIND_FULLY_CLIPPED = 2; + +export function updateClipPath( + clip: boolean, + symbolEl: Element, + clipPath: graphic.Path | NullUndefined +): void { + if (clip) { + if (__DEV__) { + assert(clipPath); + } + symbolEl.setClipPath(clipPath); + } + else { + symbolEl.removeClipPath(); + } +} diff --git a/src/chart/helper/whiskerBoxCommon.ts b/src/chart/helper/whiskerBoxCommon.ts index 294fd0eae2..8839b59e05 100644 --- a/src/chart/helper/whiskerBoxCommon.ts +++ b/src/chart/helper/whiskerBoxCommon.ts @@ -21,13 +21,18 @@ import createSeriesDataSimply from './createSeriesDataSimply'; import * as zrUtil from 'zrender/src/core/util'; import {getDimensionTypeByAxis} from '../../data/helper/dimensionHelper'; import {makeSeriesEncodeForAxisCoordSys} from '../../data/helper/sourceHelper'; -import type { SeriesOption, SeriesOnCartesianOptionMixin, LayoutOrient } from '../../util/types'; +import type { SeriesOption, SeriesOnCartesianOptionMixin, LayoutOrient, NullUndefined } from '../../util/types'; import type GlobalModel from '../../model/Global'; import type SeriesModel from '../../model/Series'; import type CartesianAxisModel from '../../coord/cartesian/AxisModel'; import type SeriesData from '../../data/SeriesData'; import type Axis2D from '../../coord/cartesian/Axis2D'; import { CoordDimensionDefinition } from '../../data/helper/createDimensions'; +import { CoordinateSystemClipArea } from '../../coord/CoordinateSystem'; +import { + SHAPE_CLIP_KIND_FULLY_CLIPPED, SHAPE_CLIP_KIND_NOT_CLIPPED, SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, + ShapeClipKind +} from './createClipPathFromCoordSys'; interface CommonOption extends SeriesOption, SeriesOnCartesianOptionMixin { // - 'horizontal': Multiple whisker boxes (each drawn vertically) @@ -44,8 +49,8 @@ interface DataItemOption { value?: number[] } -interface WhiskerBoxCommonMixin extends SeriesModel{} -class WhiskerBoxCommonMixin { +export interface WhiskerBoxCommonMixin extends SeriesModel{} +export class WhiskerBoxCommonMixin { private _baseAxisDim: string; @@ -184,5 +189,26 @@ class WhiskerBoxCommonMixin { }; - -export { WhiskerBoxCommonMixin }; +/** + * PENDING: We do not use zr Element clipPath due to performance consideration, + * although it may be further optimized. + */ +export function resolveNormalBoxClipping( + clipArea: CoordinateSystemClipArea, + itemLayout: { + ends: number[][]; + } +): ShapeClipKind { + const count = itemLayout.ends.length; + let containCount = 0; + for (let i = 0; i < count; i++) { + // clip if any points is out of the area, otherwise the shape may partially + // out of the coord sys area and overlap with axis labels. + if (clipArea.contain(itemLayout.ends[i][0], itemLayout.ends[i][1])) { + containCount++; + } + } + return !containCount ? SHAPE_CLIP_KIND_FULLY_CLIPPED + : containCount < count ? SHAPE_CLIP_KIND_PARTIALLY_CLIPPED + : SHAPE_CLIP_KIND_NOT_CLIPPED; +} diff --git a/src/component/axisPointer/BaseAxisPointer.ts b/src/component/axisPointer/BaseAxisPointer.ts index 5b91847b19..7179103010 100644 --- a/src/component/axisPointer/BaseAxisPointer.ts +++ b/src/component/axisPointer/BaseAxisPointer.ts @@ -33,6 +33,7 @@ import { VerticalAlign, HorizontalAlign, CommonAxisPointerOption } from '../../u import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; import { TextProps } from 'zrender/src/graphic/Text'; +import { calcBandWidth } from '../../coord/axisBand'; const inner = makeInner<{ lastProp?: DisplayableProps @@ -220,7 +221,7 @@ class BaseAxisPointer implements AxisPointer { if (animation === 'auto' || animation == null) { const animationThreshold = this.animationThreshold; - if (isCategoryAxis && axis.getBandWidth() > animationThreshold) { + if (isCategoryAxis && calcBandWidth(axis).w > animationThreshold) { return true; } @@ -251,7 +252,7 @@ class BaseAxisPointer implements AxisPointer { axisPointerModel: AxisPointerModel, api: ExtensionAPI ) { - // Should be implemenented by sub-class. + // Should be implemented by sub-class. } /** diff --git a/src/component/axisPointer/CartesianAxisPointer.ts b/src/component/axisPointer/CartesianAxisPointer.ts index 325768563f..2383a07e54 100644 --- a/src/component/axisPointer/CartesianAxisPointer.ts +++ b/src/component/axisPointer/CartesianAxisPointer.ts @@ -27,7 +27,8 @@ import Grid from '../../coord/cartesian/Grid'; import Axis2D from '../../coord/cartesian/Axis2D'; import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; -import { isNullableNumberFinite, mathMax, mathMin } from '../../util/number'; +import { mathMax, mathMin } from '../../util/number'; +import type GlobalModel from '../../model/Global'; // Not use top level axisPointer model type AxisPointerModel = Model; @@ -47,13 +48,19 @@ class CartesianAxisPointer extends BaseAxisPointer { const axis = axisModel.axis; const grid = axis.grid; const axisPointerType = axisPointerModel.get('type'); + const thisExtent = axis.getGlobalExtent(); const otherExtent = getCartesian(grid, axis).getOtherAxis(axis).getGlobalExtent(); const pixelValue = axis.toGlobalCoord(axis.dataToCoord(value, true)); if (axisPointerType && axisPointerType !== 'none') { const elStyle = viewHelper.buildElStyle(axisPointerModel); const pointerOption = pointerShapeBuilder[axisPointerType]( - axis, pixelValue, otherExtent + axis, + pixelValue, + thisExtent, + otherExtent, + axisPointerModel.get('seriesDataIndices'), + axisPointerModel.ecModel ); pointerOption.style = elStyle; elOption.graphicKey = pointerOption.type; @@ -143,7 +150,12 @@ function getCartesian(grid: Grid, axis: Axis2D) { const pointerShapeBuilder = { - line: function (axis: Axis2D, pixelValue: number, otherExtent: number[]): PathProps & { type: 'Line'} { + line: function ( + axis: Axis2D, + pixelValue: number, + thisExtent: number[], + otherExtent: number[] + ): PathProps & { type: 'Line'} { const targetShape = viewHelper.makeLineShape( [pixelValue, otherExtent[0]], [pixelValue, otherExtent[1]], @@ -156,19 +168,23 @@ const pointerShapeBuilder = { }; }, - shadow: function (axis: Axis2D, pixelValue: number, otherExtent: number[]): PathProps & { type: 'Rect'} { - let bandWidth = axis.getBandWidth(); - const thisExtent = axis.getGlobalExtent(); - bandWidth = isNullableNumberFinite(bandWidth) - ? mathMax(1, bandWidth) : 1; + shadow: function ( + axis: Axis2D, + pixelValue: number, + thisExtent: number[], + otherExtent: number[], + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel + ): PathProps & { type: 'Rect'} { + + const bandWidth = viewHelper.calcAxisPointerShadowBandWidth(axis, seriesDataIndices, ecModel); const otherSpan = otherExtent[1] - otherExtent[0]; - const thisX = mathMax(thisExtent[0], pixelValue - bandWidth / 2); - const thisW = mathMin(thisX + bandWidth, thisExtent[1]) - thisX; + const [min, max] = viewHelper.calcAxisPointerShadowEnds(pixelValue, thisExtent, bandWidth); return { type: 'Rect', shape: viewHelper.makeRectShape( - [thisX, otherExtent[0]], - [thisW, otherSpan], + [min, otherExtent[0]], + [max - min, otherSpan], getAxisDimIndex(axis) ) }; diff --git a/src/component/axisPointer/PolarAxisPointer.ts b/src/component/axisPointer/PolarAxisPointer.ts index 0a220342bb..5d56e9a19e 100644 --- a/src/component/axisPointer/PolarAxisPointer.ts +++ b/src/component/axisPointer/PolarAxisPointer.ts @@ -36,6 +36,7 @@ import AngleAxis from '../../coord/polar/AngleAxis'; import RadiusAxis from '../../coord/polar/RadiusAxis'; import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; +import type GlobalModel from '../../model/Global'; // Not use top level axisPointer model type AxisPointerModel = Model; @@ -59,8 +60,8 @@ class PolarAxisPointer extends BaseAxisPointer { } const polar = axis.polar; - const otherAxis = polar.getOtherAxis(axis); - const otherExtent = otherAxis.getExtent(); + const thisExtent = axis.getExtent(); + const otherExtent = polar.getOtherAxis(axis).getExtent(); const coordValue = axis.dataToCoord(value); @@ -68,7 +69,13 @@ class PolarAxisPointer extends BaseAxisPointer { if (axisPointerType && axisPointerType !== 'none') { const elStyle = viewHelper.buildElStyle(axisPointerModel); const pointerOption = pointerShapeBuilder[axisPointerType]( - axis, polar, coordValue, otherExtent + axis, + polar, + coordValue, + thisExtent, + otherExtent, + axisPointerModel.get('seriesDataIndices'), + axisPointerModel.ecModel ); pointerOption.style = elStyle; elOption.graphicKey = pointerOption.type; @@ -80,7 +87,7 @@ class PolarAxisPointer extends BaseAxisPointer { viewHelper.buildLabelElOption(elOption, axisModel, axisPointerModel, api, labelPos); } - // Do not support handle, utill any user requires it. + // Do not support handle, util any user requires it. }; @@ -139,6 +146,7 @@ const pointerShapeBuilder = { axis: AngleAxis | RadiusAxis, polar: Polar, coordValue: number, + thisExtent: number[], otherExtent: number[] ): PathProps & { type: 'Line' | 'Circle' } { return axis.dim === 'angle' @@ -163,31 +171,44 @@ const pointerShapeBuilder = { axis: AngleAxis | RadiusAxis, polar: Polar, coordValue: number, - otherExtent: number[] + thisExtent: number[], + otherExtent: number[], + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel ): PathProps & { type: 'Sector' } { - const bandWidth = Math.max(1, axis.getBandWidth()); + const radian = Math.PI / 180; + const bandWidth = viewHelper.calcAxisPointerShadowBandWidth(axis, seriesDataIndices, ecModel); + let shape; + if (axis.dim === 'angle') { + shape = viewHelper.makeSectorShape( + polar.cx, + polar.cy, + otherExtent[0], + otherExtent[1], + // In ECharts the screen y is negative if angle is positive, + // opposite to zrender shape. + // No need clamp. + (-coordValue - bandWidth / 2) * radian, + (-coordValue + bandWidth / 2) * radian + ); + } + else { + const [min, max] = viewHelper.calcAxisPointerShadowEnds(coordValue, thisExtent, bandWidth); + shape = viewHelper.makeSectorShape( + polar.cx, + polar.cy, + min, + max, + 0, + Math.PI * 2 + ); + } - return axis.dim === 'angle' - ? { - type: 'Sector', - shape: viewHelper.makeSectorShape( - polar.cx, polar.cy, - otherExtent[0], otherExtent[1], - // In ECharts y is negative if angle is positive - (-coordValue - bandWidth / 2) * radian, - (-coordValue + bandWidth / 2) * radian - ) - } - : { - type: 'Sector', - shape: viewHelper.makeSectorShape( - polar.cx, polar.cy, - coordValue - bandWidth / 2, - coordValue + bandWidth / 2, - 0, Math.PI * 2 - ) - }; + return { + type: 'Sector', + shape, + }; } }; diff --git a/src/component/axisPointer/SingleAxisPointer.ts b/src/component/axisPointer/SingleAxisPointer.ts index 8f766dd252..324cd2ae32 100644 --- a/src/component/axisPointer/SingleAxisPointer.ts +++ b/src/component/axisPointer/SingleAxisPointer.ts @@ -27,6 +27,7 @@ import { ScaleDataValue, VerticalAlign, CommonAxisPointerOption } from '../../ut import ExtensionAPI from '../../core/ExtensionAPI'; import SingleAxisModel from '../../coord/single/AxisModel'; import Model from '../../model/Model'; +import type GlobalModel from '../../model/Global'; const XY = ['x', 'y'] as const; const WH = ['width', 'height'] as const; @@ -48,14 +49,21 @@ class SingleAxisPointer extends BaseAxisPointer { ) { const axis = axisModel.axis; const coordSys = axis.coordinateSystem; - const otherExtent = getGlobalExtent(coordSys, 1 - getPointDimIndex(axis)); + const pointDimIndex = getPointDimIndex(axis); + const thisExtent = getGlobalExtent(coordSys, pointDimIndex); + const otherExtent = getGlobalExtent(coordSys, 1 - pointDimIndex); const pixelValue = coordSys.dataToPoint(value)[0]; const axisPointerType = axisPointerModel.get('type'); if (axisPointerType && axisPointerType !== 'none') { const elStyle = viewHelper.buildElStyle(axisPointerModel); const pointerOption = pointerShapeBuilder[axisPointerType]( - axis, pixelValue, otherExtent + axis, + pixelValue, + thisExtent, + otherExtent, + axisPointerModel.get('seriesDataIndices'), + axisPointerModel.ecModel ); pointerOption.style = elStyle; @@ -129,7 +137,12 @@ class SingleAxisPointer extends BaseAxisPointer { const pointerShapeBuilder = { - line: function (axis: SingleAxis, pixelValue: number, otherExtent: number[]): PathProps & { + line: function ( + axis: SingleAxis, + pixelValue: number, + thisExtent: number[], + otherExtent: number[] + ): PathProps & { type: 'Line' } { const targetShape = viewHelper.makeLineShape( @@ -144,16 +157,24 @@ const pointerShapeBuilder = { }; }, - shadow: function (axis: SingleAxis, pixelValue: number, otherExtent: number[]): PathProps & { + shadow: function ( + axis: SingleAxis, + pixelValue: number, + thisExtent: number[], + otherExtent: number[], + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel + ): PathProps & { type: 'Rect' } { - const bandWidth = axis.getBandWidth(); + const bandWidth = viewHelper.calcAxisPointerShadowBandWidth(axis, seriesDataIndices, ecModel); const span = otherExtent[1] - otherExtent[0]; + const [min, max] = viewHelper.calcAxisPointerShadowEnds(pixelValue, thisExtent, bandWidth); return { type: 'Rect', shape: viewHelper.makeRectShape( - [pixelValue - bandWidth / 2, otherExtent[0]], - [bandWidth, span], + [min, otherExtent[0]], + [max - min, span], getPointDimIndex(axis) ) }; diff --git a/src/component/axisPointer/viewHelper.ts b/src/component/axisPointer/viewHelper.ts index 04b18e0daf..762869fdc2 100644 --- a/src/component/axisPointer/viewHelper.ts +++ b/src/component/axisPointer/viewHelper.ts @@ -40,6 +40,8 @@ import Model from '../../model/Model'; import { PathStyleProps } from 'zrender/src/graphic/Path'; import { createTextStyle } from '../../label/labelStyle'; import type SingleAxisModel from '../../coord/single/AxisModel'; +import { calcBandWidth } from '../../coord/axisBand'; +import { mathMax, mathMin } from '../../util/number'; export interface AxisTransformedPositionLayoutInfo { position: VectorArray @@ -262,3 +264,38 @@ export function makeSectorShape( clockwise: true }; } + +export function calcAxisPointerShadowBandWidth( + axis: Axis, + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel +): number { + return calcBandWidth(axis, { + fromStat: { + sers: zrUtil.map(seriesDataIndices, function (item) { + return ecModel.getSeriesByIndex(item.seriesIndex); + }) + }, + min: 1 + }).w; +} + +/** + * Return a [min, max] in pixel clampped by `axisExtent`. + */ +export function calcAxisPointerShadowEnds( + val: number, + axisExtent: number[], + bandWidth: number +): number[] { + return [ + mathMax( + mathMin(axisExtent[0], axisExtent[1]), + val - bandWidth / 2 + ), + mathMin( + val + bandWidth / 2, + mathMax(axisExtent[0], axisExtent[1]) + ) + ]; +} diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index fee554bcc1..5b7d22a898 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -36,7 +36,7 @@ import { AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoReallyCreate, ScaleRawExtentResultForZoom, } from '../../coord/scaleRawExtentInfo'; -import { suppressOnAxisZero } from '../../coord/axisHelper'; +import { discourageOnAxisZero } from '../../coord/axisHelper'; interface MinMaxSpan { @@ -353,10 +353,10 @@ class AxisProxy { const axis = this.getAxisModel().axis; scaleRawExtentInfoReallyCreate(this.ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); - suppressOnAxisZero(axis, {dz: true}); + discourageOnAxisZero(axis, {dz: true}); const rawExtentInfo = axis.scale.rawExtentInfo; - this._extent = rawExtentInfo.makeForZoom(); + this._extent = rawExtentInfo.makeNoZoom(); // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); @@ -461,9 +461,9 @@ class AxisProxy { const range: Dictionary<[number, number]> = {}; range[dim] = valueWindow as [number, number]; - // console.time('select'); + // console.time('AxisProxy_selectRange'); seriesData.selectRange(range); - // console.timeEnd('select'); + // console.timeEnd('AxisProxy_selectRange'); } }); } diff --git a/src/component/polar/install.ts b/src/component/polar/install.ts index fbd61fa45f..e2cde4209e 100644 --- a/src/component/polar/install.ts +++ b/src/component/polar/install.ts @@ -34,7 +34,8 @@ import AngleAxisView from '../axis/AngleAxisView'; import RadiusAxisView from '../axis/RadiusAxisView'; import ComponentView from '../../view/Component'; import { curry } from 'zrender/src/core/util'; -import barLayoutPolar from '../../layout/barPolar'; +import { barLayoutPolar, registerBarPolarAxisHandlers } from '../../layout/barPolar'; +import { BAR_SERIES_TYPE } from '../../layout/barCommon'; const angleAxisExtraOption: AngleAxisOption = { @@ -44,6 +45,9 @@ const angleAxisExtraOption: AngleAxisOption = { splitNumber: 12, + // A round axis is not suitable for `containShape` in most cases. + containShape: false, + axisLabel: { rotate: 0 } @@ -76,5 +80,7 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerComponentView(AngleAxisView); registers.registerComponentView(RadiusAxisView); - registers.registerLayout(curry(barLayoutPolar, 'bar')); + registers.registerLayout(curry(barLayoutPolar, BAR_SERIES_TYPE)); + + registerBarPolarAxisHandlers(registers, BAR_SERIES_TYPE); } \ No newline at end of file diff --git a/src/component/tooltip/TooltipView.ts b/src/component/tooltip/TooltipView.ts index 11d965f8ec..368fac7135 100644 --- a/src/component/tooltip/TooltipView.ts +++ b/src/component/tooltip/TooltipView.ts @@ -808,7 +808,7 @@ class TooltipView extends ComponentView { } private _showTooltipContent( - // Use Model insteadof TooltipModel because this model may be from series or other options. + // Use Model instead of TooltipModel because this model may be from series or other options. // Instead of top level tooltip. tooltipModel: Model, defaultHtml: string, diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index ce36ee0051..038aacbf22 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -34,7 +34,7 @@ import Model from '../model/Model'; import { AxisBaseOption, CategoryAxisBaseOption, OptionAxisType } from './axisCommonTypes'; import { AxisBaseModel } from './AxisBaseModel'; import { isOrdinalScale } from '../scale/helper'; -import { AxisBandWidthResult, calcBandWidth } from './axisBand'; +import { calcBandWidth } from './axisBand'; const NORMALIZED_EXTENT = [0, 1] as [number, number]; @@ -65,6 +65,9 @@ class Axis { type: OptionAxisType; // Axis dimension. Such as 'x', 'y', 'z', 'angle', 'radius'. + // The name must be globally unique across different coordinate systems. + // But they may be not enumerable, e.g., in Radar and Parallel, axis + // number is not static. readonly dim: DimensionName; // Axis scale @@ -242,12 +245,11 @@ class Axis { } /** - * NOTICE: Can only be called after `adoptBandWidth` being called in `CoordinateSystem#update` stage. + * @deprecated Use `calcBandWidth` instead. */ getBandWidth(): number { - calcBandWidth(tmpOutBandWidth, this, true); + return calcBandWidth(this, {min: 1}).w; // NOTICE: Do not add logic here. Implement everthing in `calcBandWidth`. - return tmpOutBandWidth.bandWidth; } /** @@ -267,8 +269,6 @@ class Axis { } -const tmpOutBandWidth: AxisBandWidthResult = {}; - function makeExtentWithBands(axis: Axis): number[] { const extent = axis.getExtent(); if (axis.onBand) { diff --git a/src/coord/CoordinateSystem.ts b/src/coord/CoordinateSystem.ts index cc0e8158e7..c5e8253081 100644 --- a/src/coord/CoordinateSystem.ts +++ b/src/coord/CoordinateSystem.ts @@ -189,6 +189,10 @@ export interface CoordinateSystem { getAxis?: (dim?: DimensionName) => Axis; + /** + * FIXME: It may introduce inconsistency with `Series['getBaseAxis']`. + * `CoordinateSystem['getBaseAxis']` probably should not exist. + */ getBaseAxis?: () => Axis; getOtherAxis?: (baseAxis: Axis) => Axis; diff --git a/src/coord/axisBand.ts b/src/coord/axisBand.ts index b90abc2ed9..d7bc54007a 100644 --- a/src/coord/axisBand.ts +++ b/src/coord/axisBand.ts @@ -17,41 +17,59 @@ * under the License. */ -import { each } from 'zrender/src/core/util'; +import { assert, each } from 'zrender/src/core/util'; import { NullUndefined } from '../util/types'; import type Axis from './Axis'; import type Scale from '../scale/Scale'; import { isOrdinalScale } from '../scale/helper'; -import { isNullableNumberFinite, mathAbs } from '../util/number'; -import { getAxisStatistics, getAxisStatisticsKeys } from './axisStatistics'; +import { isNullableNumberFinite, mathAbs, mathMax } from '../util/number'; +import { + AxisStatKey, getAxisStat, getAxisStatBySeries, +} from './axisStatistics'; import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; +import type SeriesModel from '../model/Series'; // Arbitrary, leave some space to avoid overflowing when dataZoom moving. -const SINGULAR_BAND_WIDTH_RATIO = 0.7; +const FALLBACK_BAND_WIDTH_RATIO = 0.8; export type AxisBandWidthResult = { - // In px. After the calling of `calcBandWidth`, it may be NaN if no meaningfull bandWidth, - // but never be null/undefined any more. - bandWidth?: number | NullUndefined; - kind?: AxisBandWidthKind; - // If `AXIS_BAND_WIDTH_KIND_NORMAL`, this is a ratio from px span to data span, exists only if not singular. - // If `AXIS_BAND_WIDTH_KIND_SINGULAR`, no need any ratio. - ratio?: number | NullUndefined; + // The result `bandWidth`. In pixel. + // Never be null/undefined. + // May be NaN if no meaningfull `bandWidth`. But it's unlikely to be NaN, since edge cases + // are handled internally whenever possible. + w: number; + // Exists if `bandWidth` is calculated by `fromStat`. + fromStat?: { + // This is a ratio from pixel span to data span; The conversion can not be performed + // if it is not valid, typically when only one or no series data item exists on the axis. + invRatio?: number | NullUndefined; + }; }; -export type AxisBandWidthKind = - // NullUndefined means no bandWidth, typically due to no series data. - NullUndefined - | typeof AXIS_BAND_WIDTH_KIND_SINGULAR - | typeof AXIS_BAND_WIDTH_KIND_NORMAL; -export const AXIS_BAND_WIDTH_KIND_SINGULAR = 1; -export const AXIS_BAND_WIDTH_KIND_NORMAL = 2; +/** + * PENDING: Should the `bandWidth` strategy be chosen by users, or auto-determined basesd on + * performance? + */ +type CalculateBandWidthOpt = { + // Only used on non-'category' axes. Calculate `bandWidth` based on statistics. + // Require `requireAxisStatistics` to be called. + fromStat?: { + // Either `axisStatKey` or `series` is required. + // If multiple axis statistics can be queried by `series`, currently we only support to return a + // maximum `bandWidth`, which is suitable for cases like "axis pointer shadow". + sers?: (SeriesModel | NullUndefined)[] | NullUndefined; + key?: AxisStatKey; + }; + // It also act as a fallback for NaN/null/undefined result. + min?: number; +}; /** * NOTICE: - * Require the axis pixel extent and the scale extent as inputs. But they - * can be not precise for approximation. + * - Require the axis pixel extent and the scale extent as inputs. But they + * can be not precise for approximation. + * - Can only be called after "data processing" stage. * * PENDING: * Currently `bandWidth` can not be specified by users explicitly. But if we @@ -61,31 +79,38 @@ export const AXIS_BAND_WIDTH_KIND_NORMAL = 2; * (but before break) scale, similar to `axis.interval`. * * A band is required on: - * - bar series group band width; + * - series group band width in bar/boxplot/candlestick/...; * - tooltip axisPointer type "shadow"; * - etc. */ export function calcBandWidth( - out: AxisBandWidthResult, axis: Axis, - useFallback: boolean -): void { - // Clear out. - out.ratio = out.kind = undefined; - out.bandWidth = NaN; - + opt?: CalculateBandWidthOpt | NullUndefined +): AxisBandWidthResult { + opt = opt || {}; + const out: AxisBandWidthResult = {w: NaN}; const scale = axis.scale; + const fromStat = opt.fromStat; + const min = opt.min; - if (isOrdinalScale(scale) - || ( - !calcBandWidthForNumericAxisIfPossible(out, axis, scale) - // The fallback is only reasonable in several special cases (e.g., axis number is interger). - // So it is used only for backward compatibility. - && useFallback - ) - ) { - calcBandWidthForCategoryAxisOrFallback(out, axis, scale); + if (isOrdinalScale(scale)) { + calcBandWidthForCategoryAxis(out, axis, scale); } + else if (fromStat) { + calcBandWidthForNumericAxis(out, axis, scale, fromStat); + } + else if (min == null) { + if (__DEV__) { + assert(false); + } + } + + if (min != null) { + out.w = isNullableNumberFinite(out.w) + ? mathMax(min, out.w) : min; + } + + return out; } /** @@ -93,8 +118,10 @@ export function calcBandWidth( * * It can be used as a fallback, as it does not produce a significant negative impact * on non-category axes. + * + * @see CalculateBandWidthOpt */ -function calcBandWidthForCategoryAxisOrFallback( +function calcBandWidthForCategoryAxis( out: AxisBandWidthResult, axis: Axis, scale: Scale @@ -106,38 +133,48 @@ function calcBandWidthForCategoryAxisOrFallback( // Fix #2728, avoid NaN when only one data. len === 0 && (len = 1); - out.bandWidth = mathAbs(axisExtent[1] - axisExtent[0]) / len; + out.w = mathAbs(axisExtent[1] - axisExtent[0]) / len; } -function calcBandWidthForNumericAxisIfPossible( +/** + * @see CalculateBandWidthOpt + */ +function calcBandWidthForNumericAxis( out: AxisBandWidthResult, axis: Axis, scale: Scale, - // A falsy return indicates this method is not applicable - a fallback is needed. -): boolean { - // PENDING: Theoretically, for 'value'/'time'/'log' axis, `bandWidth` should be derived from - // series data and may vary per data items. However, we currently only derive `bandWidth` - // per serise, regardless of individual data items, until concrete requirements arise. - // Therefore, we arbitrarily choose a minimal `bandWidth` to avoid overlap if multiple - // irrelevant series reside on one axis. - let hasStat: boolean; - let linearPositiveMinGap = Infinity; - each(getAxisStatisticsKeys(axis), function (axisStatKey) { - const liMinGap = getAxisStatistics(axis, axisStatKey).linearPositiveMinGap; - if (liMinGap != null) { - hasStat = true; - if (isNullableNumberFinite(liMinGap) && liMinGap < linearPositiveMinGap) { - linearPositiveMinGap = liMinGap; - } - } - }); - if (!hasStat) { - return false; + fromStat: CalculateBandWidthOpt['fromStat'], +): void { + + let bandWidth = NaN; + let invRatio: number | NullUndefined; + + if (__DEV__) { + assert(fromStat); } - let bandWidth: number | NullUndefined; - let kind: AxisBandWidthKind | NullUndefined; - let ratio: number | NullUndefined; + let allSingularOrNone: boolean | NullUndefined; + let bandWidthInData = -Infinity; + each( + fromStat.key + ? [getAxisStat(axis, fromStat.key)] + : getAxisStatBySeries(axis, fromStat.sers || []), + function (stat) { + const liPosMinGap = stat.liPosMinGap; + // `liPosMinGap == null` may indicate that `requireAxisStatistics` + // is not used by the relevant series. We conservatively do not + // consider it as a "singular" case. + if (liPosMinGap != null && allSingularOrNone == null) { + allSingularOrNone = true; + } + if (isNullableNumberFinite(liPosMinGap)) { + if (liPosMinGap > bandWidthInData) { + bandWidthInData = liPosMinGap; + } + allSingularOrNone = false; + } + } + ); const axisExtent = axis.getExtent(); // Always use a new pxSpan because it may be changed in `grid` contain label calculation. @@ -146,20 +183,18 @@ function calcBandWidthForNumericAxisIfPossible( // `linearScaleSpan` may be `0` or `Infinity` or `NaN`, since normalizers like // `intervalScaleEnsureValidExtent` may not have been called yet. if (isNullableNumberFinite(linearScaleSpan) && linearScaleSpan > 0 - && isNullableNumberFinite(linearPositiveMinGap) + && isNullableNumberFinite(bandWidthInData) ) { - bandWidth = pxSpan / linearScaleSpan * linearPositiveMinGap; - ratio = linearScaleSpan / pxSpan; - kind = AXIS_BAND_WIDTH_KIND_NORMAL; + // NOTE: even when the `bandWidth` is far smaller than `1`, we should still preserve the + // precision, because it is required to convert back to data space by `invRatio` for + // displaying of zoomed ticks and band. + bandWidth = pxSpan / linearScaleSpan * bandWidthInData; + invRatio = linearScaleSpan / pxSpan; } - else { - bandWidth = pxSpan * SINGULAR_BAND_WIDTH_RATIO; - kind = AXIS_BAND_WIDTH_KIND_SINGULAR; + else if (allSingularOrNone) { + bandWidth = pxSpan * FALLBACK_BAND_WIDTH_RATIO; } - out.bandWidth = bandWidth; - out.kind = kind; - out.ratio = ratio; - - return true; + out.w = bandWidth; + out.fromStat = {invRatio}; } diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 32b196d1bd..7c040e9384 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -29,7 +29,6 @@ import { LabelCommonOption, } from '../util/types'; import type { PrimaryTimeUnit } from '../util/time'; -import { BaseBarSeriesSubType } from '../layout/barGrid'; export const AXIS_TYPES = {value: 1, category: 1, time: 1, log: 1} as const; @@ -138,21 +137,19 @@ export interface AxisBaseOptionCommon extends ComponentOption, /** * The gap at both ends of the axis. `[GAP, GAP]`. */ -type NumericAxisBoundaryGapOption = [NumericAxisBoundaryGapOptionItemLoose, NumericAxisBoundaryGapOptionItemLoose]; +type NumericAxisBoundaryGapOption = [NumericAxisBoundaryGapOptionItemValue, NumericAxisBoundaryGapOptionItemValue]; // It can be an absolute pixel number (like `35`), or percent (like `'30%'`) -type NumericAxisBoundaryGapOptionItemValue = number | string | NullUndefined; -export type NumericAxisBoundaryGapOptionItemLoose = - NumericAxisBoundaryGapOptionItemValue | NumericAxisBoundaryGapOptionItem; -export type NumericAxisBoundaryGapOptionItem = { - value?: NumericAxisBoundaryGapOptionItemValue | NullUndefined; - // The axis contains the series shapes if possible, instead of overlowing at the edges. - containShape?: Partial> | boolean | NullUndefined; -}; +export type NumericAxisBoundaryGapOptionItemValue = number | string | NullUndefined; export interface NumericAxisBaseOptionCommon extends AxisBaseOptionCommon { boundaryGap?: NumericAxisBoundaryGapOption; + // The axis contains the series shapes if possible, instead of overlowing at the edges. + // Key is series type, like 'bar', 'pictorialBar'. + // null/undefined means `true`. + containShape?: boolean; + /** * AxisTick and axisLabel and splitLine are calculated based on splitNumber. */ diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 4553389647..3f8563c373 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -50,18 +50,20 @@ import { extentDiffers, isLogScale, isOrdinalScale } from '../scale/helper'; import { AxisModelExtendedInCreator } from './axisModelCreator'; -import { initExtentForUnion, makeInner } from '../util/model'; -import { SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_OUT_OF_BREAK } from '../scale/scaleMapper'; +import { initExtentForUnion, isValidBoundsForExtent, makeInner } from '../util/model'; +import { + getScaleExtentForMappingUnsafe, SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_OUT_OF_BREAK +} from '../scale/scaleMapper'; import ComponentModel from '../model/Component'; const axisInner = makeInner<{ - noOnMyZero: SuppressOnAxisZeroReason; + noOnMyZero: DiscourageOnAxisZeroCondition; }, Axis>(); -type SuppressOnAxisZeroReason = { +type DiscourageOnAxisZeroCondition = { dz?: boolean; - base?: boolean + base?: boolean; }; @@ -135,31 +137,44 @@ export function createScaleByModel( } /** - * Check if the axis cross 0 + * Check if the axis cross a specific value. */ -export function ifAxisCrossZero(axis: Axis) { - // NOTE: Although the portion out of "effective" portion may also cross zero - // (see `SCALE_EXTENT_KIND_MAPPING`), that is commonly meaningless, so we use - // `SCALE_EXTENT_KIND_EFFECTIVE` - const dataExtent = axis.scale.getExtentUnsafe(SCALE_EXTENT_KIND_EFFECTIVE, null); +export function getScaleValuePositionKind( + scale: Scale, value: number, considerMappingExtent: boolean +): ScaleValuePositionKind { + const dataExtent = considerMappingExtent + ? getScaleExtentForMappingUnsafe(scale, null) + : scale.getExtentUnsafe(SCALE_EXTENT_KIND_EFFECTIVE, null); const min = dataExtent[0]; const max = dataExtent[1]; - return !((min > 0 && max > 0) || (min < 0 && max < 0)); + return !isValidBoundsForExtent(min, max) ? SCALE_VALUE_POSITION_KIND_OUTSIDE + : (min === value || max === value) ? SCALE_VALUE_POSITION_KIND_EDGE + : (min < value || max > value) ? SCALE_VALUE_POSITION_KIND_INSIDE + : SCALE_VALUE_POSITION_KIND_OUTSIDE; } +export type ScaleValuePositionKind = + typeof SCALE_VALUE_POSITION_KIND_INSIDE + | typeof SCALE_VALUE_POSITION_KIND_EDGE + | typeof SCALE_VALUE_POSITION_KIND_OUTSIDE; +export const SCALE_VALUE_POSITION_KIND_INSIDE = 1; +export const SCALE_VALUE_POSITION_KIND_EDGE = 2; +export const SCALE_VALUE_POSITION_KIND_OUTSIDE = 3; + -export function suppressOnAxisZero(axis: Axis, reason: Partial): void { +export function discourageOnAxisZero(axis: Axis, reason: Partial): void { zrUtil.defaults(axisInner(axis).noOnMyZero || (axisInner(axis).noOnMyZero = {}), reason); } /** * `true`: Prevent orthoganal axes from positioning at the zero point of this axis. */ -export function isOnAxisZeroSuppressed(axis: Axis): boolean { - const dontOnAxisZero = axisInner(axis).noOnMyZero; - // Empirically, onZero causes weired effect when dataZoom is used on an "base axis". Consider +export function isOnAxisZeroDiscouraged(axis: Axis): boolean { + const noOnMyZero = axisInner(axis).noOnMyZero; + // Empirically, onZero causes weird effect when dataZoom is used on an "base axis". Consider // bar series as an example. And also consider when `SCALE_EXTENT_KIND_MAPPING` is used, where // the axis line is likely to cross the series shapes unexpectedly. - return dontOnAxisZero && dontOnAxisZero.dz && dontOnAxisZero.base; + // Conservatively, we use "&&" rather than "||" here. + return noOnMyZero && noOnMyZero.dz && noOnMyZero.base; } /** diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts index c677a4337d..f64ae1faa4 100644 --- a/src/coord/axisStatistics.ts +++ b/src/coord/axisStatistics.ts @@ -17,34 +17,75 @@ * under the License. */ -import { assert, clone, createHashMap, each, HashMap } from 'zrender/src/core/util'; +import { assert, createHashMap, each, HashMap } from 'zrender/src/core/util'; import type GlobalModel from '../model/Global'; import type SeriesModel from '../model/Series'; import { - extentHasValue, getCachePerECFullUpdate, GlobalModelCachePerECFullUpdate, - initExtentForUnion, makeInner, + getCachePerECFullUpdate, getCachePerECPrepare, GlobalModelCachePerECFullUpdate, + GlobalModelCachePerECPrepare, + initExtentForUnion, makeCallOnlyOnce, makeInner, } from '../util/model'; -import { NullUndefined } from '../util/types'; +import { DimensionIndex, NullUndefined } from '../util/types'; import type Axis from './Axis'; -import { asc, isNullableNumberFinite, mathMin } from '../util/number'; -import { registerPerformAxisStatistics } from '../core/CoordinateSystem'; +import { asc, isNullableNumberFinite } from '../util/number'; import { parseSanitizationFilter, passesSanitizationFilter } from '../data/helper/dataValueHelper'; +import type DataStore from '../data/DataStore'; +import type { AxisBaseModel } from './AxisBaseModel'; +import { tryEnsureTypedArray, Float64ArrayCtor } from '../util/vendor'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import type ComponentModel from '../model/Component'; -const ecModelCacheInner = makeInner<{ - axes: Axis[]; +const callOnlyOnce = makeCallOnlyOnce(); + +const ecModelCacheFullUpdateInner = makeInner<{ + all: AxisStatAll; + keys: AxisStatKeys; + axisMapForCheck?: HashMap<1, ComponentModel['uid']>; // Only used in dev mode + seriesMapForCheck?: HashMap<1, ComponentModel['uid']>; // Only used in dev mode }, GlobalModelCachePerECFullUpdate>(); -type AxisStatisticsStore = { - stat: AxisStatisticsPerAxis | NullUndefined; - // For duplication checking. - added: boolean + +type AxisStatKeys = HashMap; +type AxisStatAll = HashMap; +type AxisStatPerKey = HashMap; +type AxisStatPerKeyPerAxis = { + axis: Axis; + // This is series use this axis as base axis and need to be laid out. + // The order is determined by the client and must be respected. + sers: SeriesModel[]; + // For query. The array index is series index. + serByIdx: SeriesModel[]; + // Minimal positive gap of values (in the linear space) of all relevant series (e.g. per `BaseBarSeriesSubType`) + // on this axis. + // Be `null`/`undefined` if this metric is not calculated. + // Be `NaN` if no meaningful gap can be calculated, typically when only one item an no item. + liPosMinGap?: number | NullUndefined; + + // metrics corresponds to this record. + metrics?: AxisStatisticsMetrics; + // ecPrepareCache corresponds to this record. + ecPrepare?: AxisStatECPrepareCachePerKeyPerAxis; }; -const axisInner = makeInner(); + +const ecModelCachePrepareInner = makeInner<{ + all: AxisStatECPrepareCacheAll | NullUndefined; +}, GlobalModelCachePerECPrepare>(); + +type AxisStatECPrepareCacheAll = HashMap; +type AxisStatECPrepareCachePerKey = HashMap; +type AxisStatECPrepareCachePerKeyPerAxis = + Pick & { + // Used for cache validity. + serUids?: HashMap<1, ComponentModel['uid']> + }; export type AxisStatisticsClient = { + /** + * NOTICE: It is called after series filtering. + */ collectAxisSeries: ( ecModel: GlobalModel, - saveAxisSeries: (axis: Axis, series: SeriesModel) => void + saveAxisSeries: (axis: Axis | NullUndefined, series: SeriesModel) => void ) => void; getMetrics: ( axis: Axis, @@ -52,160 +93,286 @@ export type AxisStatisticsClient = { }; /** - * Nominal to avoid misusing. - * Sample usage: - * function axisStatKey(seriesType: ComponentSubType): AxisStatisticsKey { - * return `xxx-${seriesType}` as AxisStatisticsKey; - * } + * Within each individual axis, different groups of relevant series and statistics are + * designated by `AxisStatKey`. In most case `seriesType` is used as `AxisStatKey`. */ -export type AxisStatisticsKey = string & {_: 'AxisStatisticsKey'}; +export type AxisStatKey = string & {_: 'AxisStatKey'}; // Nominal to avoid misusing. type AxisStatisticsMetrics = { // Currently only one metric is required. - // NOTICE: - // May be time-consuming due to some metrics requiring travel and sort of series data, - // especially when axis break is used, so it is performed only if required. - minGap?: boolean -}; - -type AxisStatisticsPerAxis = HashMap; -type AxisStatisticsPerAxisPerKey = { - // Mark that any statistics has been performed in this record. Also for duplication checking. - added?: boolean - // This is series use this axis as base axis and need to be laid out. - sers: SeriesModel[]; - // Minimal positive gap of values of all relevant series (e.g. per `BaseBarSeriesSubType`) on this axis. - // Be `NaN` if no valid data item or only one valid data item. - // Be `null`/`undefined` if this statistics is not performed. - linearPositiveMinGap?: number | NullUndefined; - // min/max of values of all relevant series (e.g. per `BaseBarSeriesSubType`) on this axis. - // Be `null`/`undefined` if this statistics is not performed, - // otherwise it is an array, but may contain `NaN` if no valid data. - linearValueExtent?: number[] | NullUndefined; + // NOTICE: + // May be time-consuming in large data due to some metrics requiring travel and sort of + // series data, especially when axis break is used, so it is performed only if required, + // and stop calculation if the iteration is over `MIN_GAP_FOR_BAND_CALCULATION_LIMIT`. + liPosMinGap?: boolean }; export type AxisStatisticsResult = Pick< - AxisStatisticsPerAxisPerKey, - 'linearPositiveMinGap' | 'linearValueExtent' + AxisStatPerKeyPerAxis, + 'liPosMinGap' >; -function ensureAxisStatisticsPerAxisPerKey( - axisStore: AxisStatisticsStore, axisStatKey: AxisStatisticsKey -): AxisStatisticsPerAxisPerKey { +let validateInputAxis: ((axis: Axis) => void) | NullUndefined; +if (__DEV__) { + validateInputAxis = function (axis) { + assert(axis && axis.model && axis.model.uid && axis.model.ecModel); + }; +} + +function getAxisStatPerKeyPerAxis( + axis: Axis, + axisStatKey: AxisStatKey +): AxisStatPerKeyPerAxis | NullUndefined { + const axisModel = axis.model; + const all = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(axisModel.ecModel)).all; + const perKey = all && all.get(axisStatKey); + return perKey && perKey.get(axisModel.uid); +} + +export function getAxisStat( + axis: Axis, + axisStatKey: AxisStatKey + // Return: Never return null/undefined. +): AxisStatisticsResult { if (__DEV__) { assert(axisStatKey != null); + validateInputAxis(axis); } - const stat = axisStore.stat || (axisStore.stat = createHashMap()); - return stat.get(axisStatKey) - || stat.set(axisStatKey, {sers: []}); + return wrapStatResult(getAxisStatPerKeyPerAxis(axis, axisStatKey)); } -export function getAxisStatistics( +export function getAxisStatBySeries( axis: Axis, - axisStatKey: AxisStatisticsKey - // Never return null/undefined. -): AxisStatisticsResult { - const record = ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey); - return { - linearPositiveMinGap: record.linearPositiveMinGap, - linearValueExtent: clone(record.linearValueExtent), - }; + seriesList: (SeriesModel | NullUndefined)[] + // Return: Never be null/undefined; never contain null/undefined. +): AxisStatisticsResult[] { + if (__DEV__) { + validateInputAxis(axis); + } + const result: AxisStatisticsResult[] = []; + eachPerKeyPerAxis(axis.model.ecModel, function (perKeyPerAxis) { + for (let idx = 0; idx < seriesList.length; idx++) { + if (seriesList[idx] && perKeyPerAxis.serByIdx[seriesList[idx].seriesIndex]) { + result.push(wrapStatResult(perKeyPerAxis)); + } + } + }); + return result; +} + +function eachPerKeyPerAxis( + ecModel: GlobalModel, + cb: ( + perKeyPerAxis: AxisStatPerKeyPerAxis, + axisStatKey: AxisStatKey, + axisModelUid: AxisBaseModel['uid'] + ) => void +): void { + const all = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).all; + all && all.each(function (perKey, axisStatKey) { + perKey.each(function (perKeyPerAxis, axisModelUid) { + cb(perKeyPerAxis, axisStatKey, axisModelUid); + }); + }); } -export function getAxisStatisticsKeys( - axis: Axis -): AxisStatisticsKey[] { - const stat = axisInner(axis).stat; - return stat ? stat.keys() : []; +function wrapStatResult(record: AxisStatPerKeyPerAxis | NullUndefined): AxisStatisticsResult { + return { + liPosMinGap: record ? record.liPosMinGap : undefined, + }; } export function eachCollectedSeries( axis: Axis, - axisStatKey: AxisStatisticsKey, - cb: (series: SeriesModel) => void + axisStatKey: AxisStatKey, + cb: (series: SeriesModel, idx: number) => void ): void { - each(ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey).sers, cb); + if (__DEV__) { + validateInputAxis(axis); + } + const perKeyPerAxis = getAxisStatPerKeyPerAxis(axis, axisStatKey); + perKeyPerAxis && each(perKeyPerAxis.sers, cb); } export function getCollectedSeriesLength( axis: Axis, - axisStatKey: AxisStatisticsKey, + axisStatKey: AxisStatKey, ): number { - return ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey).sers.length; + if (__DEV__) { + assert(axisStatKey != null); + validateInputAxis(axis); + } + const perKeyPerAxis = getAxisStatPerKeyPerAxis(axis, axisStatKey); + return perKeyPerAxis ? perKeyPerAxis.sers.length : 0; } export function eachCollectedAxis( ecModel: GlobalModel, + axisStatKey: AxisStatKey, cb: (axis: Axis) => void ): void { - each(ecModelCacheInner(getCachePerECFullUpdate(ecModel)).axes, cb); + if (__DEV__) { + assert(axisStatKey != null); + } + const all = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).all; + const perKey = all && all.get(axisStatKey); + perKey && perKey.each(function (perKeyPerAxis) { + cb(perKeyPerAxis.axis); + }); +} + +export function eachAxisStatKey( + axis: Axis, + cb: (axisStatKey: AxisStatKey) => void +): void { + if (__DEV__) { + validateInputAxis(axis); + } + const keys = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(axis.model.ecModel)).keys; + keys && keys.each(function (axisStatKeyList) { + for (let i = 0; i < axisStatKeyList.length; i++) { + cb(axisStatKeyList[i]); + } + }); } -/** - * Perform statistics if required. - */ -function performAxisStatisticsImpl(ecModel: GlobalModel): void { - const ecCache = ecModelCacheInner(getCachePerECFullUpdate(ecModel)); - const distinctAxes: Axis[] = ecCache.axes = []; +function performAxisStatistics(ecModel: GlobalModel): void { + const ecFullUpdateCache = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)); + const axisStatAll: AxisStatAll = ecFullUpdateCache.all = createHashMap(); - const records: AxisStatisticsPerAxisPerKey[] = []; - const recordAxes: Axis[] = []; - const recordMetricsList: AxisStatisticsMetrics[] = []; + const ecPrepareCache = ecModelCachePrepareInner(getCachePerECPrepare(ecModel)); + const ecPrepareCacheAll = ecPrepareCache.all || (ecPrepareCache.all = createHashMap()); axisStatisticsClients.each(function (client, axisStatKey) { - client.collectAxisSeries( - ecModel, - function saveAxisSeries(axis, series): void { - const axisStore = axisInner(axis); - if (!axisStore.added) { - axisStore.added = true; - distinctAxes.push(axis); - } - const record = ensureAxisStatisticsPerAxisPerKey(axisStore, axisStatKey); - if (!record.added) { - record.added = true; - records.push(record); - recordAxes.push(axis); - recordMetricsList.push(client.getMetrics(axis) || {}); - } - // NOTICE: series order should respect to the input order, - // since it matters in some cases (see `barGrid`). - record.sers.push(series); + client.collectAxisSeries(ecModel, function saveAxisSeries(axis, series) { + if (!axis) { + return; + } + + if (__DEV__) { + validateInputAxis(axis); + // - An axis can be associated with multiple `axisStatKey`s. For example, if `axisStatKey`s are + // "candlestick" and "bar", they can be associated with the same "xAxis". + // - Within an individual axis, it is a typically incorrect usage if a pair is + // associated with multiple `perKeyPerAxis`, which may cause repeated calculation and + // performance degradation, had hard to be found without the checking below. For example, If + // `axisStatKey` are "grid-bar" (see `barGrid.ts`) and "polar-bar" (see `barPolar.ts`), and + // a pair is wrongly associated with both "polar-bar" and "grid-bar", the + // relevant statistics will be computed twice. + const axisMapForCheck = ecFullUpdateCache.axisMapForCheck + || (ecFullUpdateCache.axisMapForCheck = createHashMap()); + const seriesMapForCheck = ecFullUpdateCache.seriesMapForCheck + || (ecFullUpdateCache.seriesMapForCheck = createHashMap()); + assert(!axisMapForCheck.get(axis.model.uid) || !seriesMapForCheck.get(series.uid)); + axisMapForCheck.set(axis.model.uid, 1); + seriesMapForCheck.set(series.uid, 1); } - ); + + const perKey = axisStatAll.get(axisStatKey) || axisStatAll.set(axisStatKey, createHashMap()); + + const axisModelUid = axis.model.uid; + let perKeyPerAxis = perKey.get(axisModelUid); + if (!perKeyPerAxis) { + perKeyPerAxis = perKey.set(axisModelUid, {axis, sers: [], serByIdx: []}); + perKeyPerAxis.metrics = client.getMetrics(axis) || {}; + + const ecPrepareCachePerKey = ecPrepareCacheAll.get(axisStatKey) + || ecPrepareCacheAll.set(axisStatKey, createHashMap()); + perKeyPerAxis.ecPrepare = ecPrepareCachePerKey.get(axisModelUid) + || ecPrepareCachePerKey.set(axisModelUid, {}); + } + // NOTICE: series order should respect to the input order, since it + // matters in some cases (see `axisSnippets.ts` for more details). + perKeyPerAxis.sers.push(series); + perKeyPerAxis.serByIdx[series.seriesIndex] = series; + }); }); - each(records, function (record, idx) { - performStatisticsForRecord(record, recordMetricsList[idx], recordAxes[idx]); + const axisStatKeys: AxisStatKeys = ecFullUpdateCache.keys = createHashMap(); + eachPerKeyPerAxis(ecModel, function (perKeyPerAxis, axisStatKey, axisModelUid) { + (axisStatKeys.get(axisModelUid) || axisStatKeys.set(axisModelUid, [])) + .push(axisStatKey); + performStatisticsForRecord(perKeyPerAxis); }); } function performStatisticsForRecord( - record: AxisStatisticsPerAxisPerKey, - metrics: AxisStatisticsMetrics, - axis: Axis, + perKeyPerAxis: AxisStatPerKeyPerAxis, ): void { - if (!metrics.minGap) { + if (!perKeyPerAxis.metrics.liPosMinGap) { return; } - const linearValueExtent = initExtentForUnion(); + const newSerUids: AxisStatECPrepareCachePerKeyPerAxis['serUids'] = createHashMap(); + const ecPreparePerKeyPerAxis = perKeyPerAxis.ecPrepare; + const ecPrepareSerUids = ecPreparePerKeyPerAxis.serUids; + const ecPrepareLiPosMinGap = ecPreparePerKeyPerAxis.liPosMinGap; + let ecPrepareCacheMiss: boolean; + + const axis = perKeyPerAxis.axis; const scale = axis.scale; + const linearValueExtent = initExtentForUnion(); const needTransform = scale.needTransform(); const filter = scale.getFilter ? scale.getFilter() : null; const filterParsed = parseSanitizationFilter(filter); - let valIdx = 0; - each(record.sers, function (seriesModel) { - const data = seriesModel.getData(); - const dimIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); - const store = data.getStore(); + // const timeRetrieve: number[] = []; // _EC_PERF_ + // const timeSort: number[] = []; // _EC_PERF_ + // const timeAll: number[] = []; // _EC_PERF_ + // timeAll[0] = Date.now(); // _EC_PERF_ + function eachSeries( + cb: (dimStoreIdx: DimensionIndex, seriesModel: SeriesModel, store: DataStore) => void + ) { + for (let i = 0; i < perKeyPerAxis.sers.length; i++) { + const seriesModel = perKeyPerAxis.sers[i]; + const data = seriesModel.getData(); + // NOTE: Currently there is no series that a "base axis" can map to multiple dimensions. + const dimStoreIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); + if (dimStoreIdx >= 0) { + cb(dimStoreIdx, seriesModel, data.getStore()); + } + } + } + + let bufferCapacity = 0; + eachSeries(function (dimStoreIdx, seriesModel, store) { + newSerUids.set(seriesModel.uid, 1); + if (!ecPrepareSerUids || !ecPrepareSerUids.hasKey(seriesModel.uid)) { + ecPrepareCacheMiss = true; + } + bufferCapacity += store.count(); + }); + + if (!ecPrepareSerUids || ecPrepareSerUids.keys().length !== newSerUids.keys().length) { + ecPrepareCacheMiss = true; + } + if (!ecPrepareCacheMiss && ecPrepareLiPosMinGap != null) { + // Consider the fact in practice: + // - Series data can only be changed in the "ec prepare" stage. + // - The relationship between series and axes can only be changed in "ec prepare" stage and + // `SERIES_FILTER`. + // (NOTE: "ec prepare" stage can be typically considered as `chart.setOption`, and "ec updated" + // stage can be typically considered as `dispatchAction`.) + // Therefore, some statistics results can be cached in `GlobalModelCachePerECPrepare` to avoid + // repeated time-consuming calculation for large data (e.g., over 1e5 data items). + perKeyPerAxis.liPosMinGap = ecPrepareLiPosMinGap; + return; + } + + tryEnsureTypedArray(tmpValueBuffer, bufferCapacity); + + // timeRetrieve[0] = Date.now(); // _EC_PERF_ + let writeIdx = 0; + eachSeries(function (dimStoreIdx, seriesModel, store) { + // NOTE: It appears to be optimized by traveling only in a specific window (e.g., the current window) + // instead of the entire data, but that would likely generate inconsistent result and bring + // jitter when dataZoom roaming. for (let i = 0, cnt = store.count(); i < cnt; ++i) { // Manually inline some code for performance, since no other optimization // (such as, progressive) can be applied here. - let val = store.get(dimIdx, i) as number; + let val = store.get(dimStoreIdx, i) as number; // NOTE: in most cases, filter does not exist. if (isFinite(val) && (!filter || passesSanitizationFilter(filterParsed, val)) @@ -214,49 +381,80 @@ function performStatisticsForRecord( // PENDING: time-consuming if axis break is applied. val = scale.transformIn(val, null); } - tmpStaticPSFRValues[valIdx++] = val; + tmpValueBuffer.arr[writeIdx++] = val; val < linearValueExtent[0] && (linearValueExtent[0] = val); val > linearValueExtent[1] && (linearValueExtent[1] = val); } } }); - tmpStaticPSFRValues.length = valIdx; + // Indicatively, retrieving values above costs 40ms for 1e6 values in a certain platform. + // timeRetrieve[1] = Date.now(); // _EC_PERF_ + + const tmpValueBufferView = tmpValueBuffer.typed + ? (tmpValueBuffer.arr as Float64Array).subarray(0, writeIdx) + : ((tmpValueBuffer.arr as number[]).length = writeIdx, tmpValueBuffer.arr); + + // timeSort[0] = Date.now(); // _EC_PERF_ + // Sort axis values into ascending order to calculate gaps. + if (tmpValueBuffer.typed) { + // Indicatively, 5ms for 1e6 values in a certain platform. + tmpValueBufferView.sort(); + } + else { + asc(tmpValueBufferView as number[]); + } + // timeAll[1] = timeSort[1] = Date.now(); // _EC_PERF_ - // Sort axis values into ascending order to calculate gaps - asc(tmpStaticPSFRValues); + // console.log('axisStatistics_minGap_retrieve', timeRetrieve[1] - timeRetrieve[0]); // _EC_PERF_ + // console.log('axisStatistics_minGap_sort', timeSort[1] - timeSort[0]); // _EC_PERF_ + // console.log('axisStatistics_minGap_all', timeAll[1] - timeAll[0]); // _EC_PERF_ let min = Infinity; - for (let j = 1; j < valIdx; ++j) { - const delta = tmpStaticPSFRValues[j] - tmpStaticPSFRValues[j - 1]; + for (let j = 1; j < writeIdx; ++j) { + const delta = tmpValueBufferView[j] - tmpValueBufferView[j - 1]; if (// - Different series normally have the same values, which should be ignored. // - A single series with multiple same values is often not meaningful to // create `bandWidth`, so it is also ignored. delta > 0 + && delta < min ) { - min = mathMin(min, delta); + min = delta; } } - record.linearPositiveMinGap = isNullableNumberFinite(min) + ecPreparePerKeyPerAxis.liPosMinGap = perKeyPerAxis.liPosMinGap = isNullableNumberFinite(min) ? min : NaN; // No valid data item or single valid data item. - if (!extentHasValue(linearValueExtent)) { - linearValueExtent[0] = linearValueExtent[1] = NaN; // No valid data. - } - record.linearValueExtent = linearValueExtent; + ecPreparePerKeyPerAxis.serUids = newSerUids; } -const tmpStaticPSFRValues: number[] = []; // A quick performance optimization. +// For performance optimization. +const tmpValueBuffer = tryEnsureTypedArray( + {ctor: Float64ArrayCtor}, + 50 // arbitrary. May be expanded if needed. +); + +/** + * NOTICE: Can only be called in "install" stage. + * + * See `axisSnippets.ts` for some commonly used clients. + */ export function requireAxisStatistics( - axisStatKey: AxisStatisticsKey, + registers: EChartsExtensionInstallRegisters, + axisStatKey: AxisStatKey, client: AxisStatisticsClient ): void { if (__DEV__) { assert(!axisStatisticsClients.get(axisStatKey)); } - registerPerformAxisStatistics(performAxisStatisticsImpl); axisStatisticsClients.set(axisStatKey, client); + + callOnlyOnce(registers, function () { + registers.registerProcessor(registers.PRIORITY.PROCESSOR.AXIS_STATISTICS, { + overallReset: performAxisStatistics + }); + }); } -const axisStatisticsClients: HashMap = createHashMap(); +const axisStatisticsClients: HashMap = createHashMap(); diff --git a/src/coord/cartesian/Cartesian2D.ts b/src/coord/cartesian/Cartesian2D.ts index fc56cc2b54..071f83b4d2 100644 --- a/src/coord/cartesian/Cartesian2D.ts +++ b/src/coord/cartesian/Cartesian2D.ts @@ -23,7 +23,7 @@ import Cartesian from './Cartesian'; import { ScaleDataValue } from '../../util/types'; import Axis2D from './Axis2D'; import { CoordinateSystem } from '../CoordinateSystem'; -import GridModel from './GridModel'; +import GridModel, { COORD_SYS_TYPE_CARTESIAN_2D } from './GridModel'; import Grid from './Grid'; import Scale from '../../scale/Scale'; import { invert } from 'zrender/src/core/matrix'; @@ -40,7 +40,7 @@ function canCalculateAffineTransform(scale: Scale) { class Cartesian2D extends Cartesian implements CoordinateSystem { - readonly type = 'cartesian2d'; + readonly type = COORD_SYS_TYPE_CARTESIAN_2D; readonly dimensions = cartesian2DDimensions; diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 698b309d81..1a2a2c1318 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -27,13 +27,16 @@ import {isObject, each, indexOf, retrieve3, keys, assert, eqNaN, find, retrieve2 import {BoxLayoutReferenceResult, createBoxLayoutReference, getLayoutRect, LayoutRect} from '../../util/layout'; import { createScaleByModel, - ifAxisCrossZero, + getScaleValuePositionKind, isNameLocationCenter, shouldAxisShow, retrieveAxisBreaksOption, determineAxisType, - suppressOnAxisZero, - isOnAxisZeroSuppressed, + discourageOnAxisZero, + isOnAxisZeroDiscouraged, + SCALE_VALUE_POSITION_KIND_OUTSIDE, + SCALE_VALUE_POSITION_KIND_EDGE, + SCALE_VALUE_POSITION_KIND_INSIDE, } from '../../coord/axisHelper'; import Cartesian2D, {cartesian2DDimensions} from './Cartesian2D'; import Axis2D from './Axis2D'; @@ -447,7 +450,7 @@ class Grid implements CoordinateSystemMaster { cartesian.addAxis(xAxis); cartesian.addAxis(yAxis); - suppressOnAxisZero(cartesian.getBaseAxis(), {base: true}); + discourageOnAxisZero(cartesian.getBaseAxis(), {base: true}); }); }); @@ -676,10 +679,28 @@ function canOnZeroToAxis( onZeroOption: AxisBaseOptionCommon['axisLine']['onZero'], axis: Axis2D ): boolean { - // PENDING: Historical behavior: `onZero` on 'category' and 'time' axis are always disabled - // even if ec option gives `onZero: true`. - let can = axis && axis.type !== 'category' && axis.type !== 'time' && ifAxisCrossZero(axis); - if (can && onZeroOption === 'auto' && isOnAxisZeroSuppressed(axis)) { + const scale = axis.scale; + const kindEffective = getScaleValuePositionKind(scale, 0, false); + + let can = axis + // PENDING: Historical behavior: `onZero` on 'category' and 'time' axis are always disabled + // even if ec option gives `onZero: true`. + && axis.type !== 'category' && axis.type !== 'time' + // NOTE: Although the portion out of "effective" portion may also cross zero + // (see `SCALE_EXTENT_KIND_MAPPING`), that is commonly meaningless, so we use + // `SCALE_EXTENT_KIND_EFFECTIVE` + && kindEffective !== SCALE_VALUE_POSITION_KIND_OUTSIDE; + + if (can && onZeroOption === 'auto' + && ( + isOnAxisZeroDiscouraged(axis) + || ( + // Avoid axis line cross series shape (typically, bar series on "value"/"time" axis) unexpectedly. + kindEffective === SCALE_VALUE_POSITION_KIND_EDGE + && getScaleValuePositionKind(scale, 0, true) === SCALE_VALUE_POSITION_KIND_INSIDE + ) + ) + ) { can = false; } // falsy value of `onZeroOption` has been handled in the previous logic. diff --git a/src/coord/cartesian/GridModel.ts b/src/coord/cartesian/GridModel.ts index ba8341841b..edef4d51a8 100644 --- a/src/coord/cartesian/GridModel.ts +++ b/src/coord/cartesian/GridModel.ts @@ -34,6 +34,8 @@ import tokens from '../../visual/tokens'; export const OUTER_BOUNDS_DEFAULT = {left: 0, right: 0, top: 0, bottom: 0}; export const OUTER_BOUNDS_CLAMP_DEFAULT = ['25%', '25%']; +export const COORD_SYS_TYPE_CARTESIAN_2D = 'cartesian2d'; + export interface GridOption extends ComponentOption, ComponentOnCalendarOptionMixin, ComponentOnMatrixOptionMixin, BoxLayoutOptionMixin, ShadowOptionMixin { diff --git a/src/coord/cartesian/prepareCustom.ts b/src/coord/cartesian/prepareCustom.ts index 1244be90e4..5ce1ae5594 100644 --- a/src/coord/cartesian/prepareCustom.ts +++ b/src/coord/cartesian/prepareCustom.ts @@ -19,6 +19,7 @@ import * as zrUtil from 'zrender/src/core/util'; import Cartesian2D from './Cartesian2D'; +import { calcBandWidth } from '../axisBand'; function dataToCoordSize(this: Cartesian2D, dataSize: number[], dataItem: number[]): number[] { // dataItem is necessary in log axis. @@ -28,7 +29,7 @@ function dataToCoordSize(this: Cartesian2D, dataSize: number[], dataItem: number const val = dataItem[dimIdx]; const halfSize = dataSize[dimIdx] / 2; return axis.type === 'category' - ? axis.getBandWidth() + ? calcBandWidth(axis).w : Math.abs(axis.dataToCoord(val - halfSize) - axis.dataToCoord(val + halfSize)); }, this); } diff --git a/src/coord/polar/Polar.ts b/src/coord/polar/Polar.ts index 609517ecf8..76a49bfc4f 100644 --- a/src/coord/polar/Polar.ts +++ b/src/coord/polar/Polar.ts @@ -19,7 +19,7 @@ import RadiusAxis from './RadiusAxis'; import AngleAxis from './AngleAxis'; -import PolarModel from './PolarModel'; +import PolarModel, { COORD_SYS_TYPE_POLAR } from './PolarModel'; import { CoordinateSystem, CoordinateSystemMaster, CoordinateSystemClipArea } from '../CoordinateSystem'; import GlobalModel from '../../model/Global'; import { ParsedModelFinder, ParsedModelFinderKnown } from '../../util/model'; @@ -37,7 +37,7 @@ class Polar implements CoordinateSystem, CoordinateSystemMaster { readonly dimensions = polarDimensions; - readonly type = 'polar'; + readonly type = COORD_SYS_TYPE_POLAR; /** * x of polar center @@ -117,7 +117,6 @@ class Polar implements CoordinateSystem, CoordinateSystemMaster { /** * Base axis will be used on stacking. - * */ getBaseAxis() { return this.getAxesByScale('ordinal')[0] diff --git a/src/coord/polar/PolarModel.ts b/src/coord/polar/PolarModel.ts index 60133aaa7c..da158d3772 100644 --- a/src/coord/polar/PolarModel.ts +++ b/src/coord/polar/PolarModel.ts @@ -32,8 +32,10 @@ export interface PolarOption extends mainType?: 'polar'; } +export const COORD_SYS_TYPE_POLAR = 'polar'; + class PolarModel extends ComponentModel { - static type = 'polar' as const; + static type = COORD_SYS_TYPE_POLAR; type = PolarModel.type; static dependencies = ['radiusAxis', 'angleAxis']; diff --git a/src/coord/polar/prepareCustom.ts b/src/coord/polar/prepareCustom.ts index ebbd99783b..7349ef6532 100644 --- a/src/coord/polar/prepareCustom.ts +++ b/src/coord/polar/prepareCustom.ts @@ -20,6 +20,7 @@ import * as zrUtil from 'zrender/src/core/util'; import Polar from './Polar'; import RadiusAxis from './RadiusAxis'; +import { calcBandWidth } from '../axisBand'; // import AngleAxis from './AngleAxis'; function dataToCoordSize(this: Polar, dataSize: number[], dataItem: number[]) { @@ -33,7 +34,7 @@ function dataToCoordSize(this: Polar, dataSize: number[], dataItem: number[]) { const halfSize = dataSize[dimIdx] / 2; let result = axis.type === 'category' - ? axis.getBandWidth() + ? calcBandWidth(axis).w : Math.abs(axis.dataToCoord(val - halfSize) - axis.dataToCoord(val + halfSize)); if (dim === 'Angle') { diff --git a/src/coord/radar/RadarModel.ts b/src/coord/radar/RadarModel.ts index 9107f58387..7fb1fbea01 100644 --- a/src/coord/radar/RadarModel.ts +++ b/src/coord/radar/RadarModel.ts @@ -35,6 +35,7 @@ import { AxisBaseModel } from '../AxisBaseModel'; import Radar, { RADAR_DEFAULT_SPLIT_NUMBER } from './Radar'; import {CoordinateSystemHostModel} from '../../coord/CoordinateSystem'; import tokens from '../../visual/tokens'; +import { getUID } from '../../util/component'; const valueAxisDefault = axisDefault.value; @@ -173,6 +174,9 @@ class RadarModel extends ComponentModel implements CoordinateSystem // For triggerEvent. model.mainType = 'radar'; model.componentIndex = this.componentIndex; + // FIXME: construct an AxisBaseModel directly, rather than mixin. + // @ts-ignore + model.uid = getUID('ec_radar'); return model; }, this); diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 1579d24520..d9d7ed6b41 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -17,13 +17,15 @@ * under the License. */ -import { assert, isArray, eqNaN, isFunction, each, HashMap, isObject } from 'zrender/src/core/util'; +import { + assert, isArray, eqNaN, isFunction, each, HashMap, createHashMap +} from 'zrender/src/core/util'; import Scale from '../scale/Scale'; import { AxisBaseModel } from './AxisBaseModel'; import { parsePercent } from 'zrender/src/contain/text'; import { - NumericAxisBaseOptionCommon, NumericAxisBoundaryGapOptionItem, - NumericAxisBoundaryGapOptionItemLoose + NumericAxisBaseOptionCommon, + NumericAxisBoundaryGapOptionItemValue, } from './axisCommonTypes'; import { ComponentSubType, DimensionIndex, DimensionName, NullUndefined, ScaleDataValue } from '../util/types'; import { isIntervalScale, isLogScale, isOrdinalScale, isTimeScale } from '../scale/helper'; @@ -34,7 +36,6 @@ import { unionExtentFromExtent, unionExtentStartFromNumber, unionExtentEndFromNumber, - isValidBoundsForExtent } from '../util/model'; import { getDataDimensionsOnAxis } from './axisHelper'; import { @@ -45,6 +46,7 @@ import { error } from '../util/log'; import type Axis from './Axis'; import { mathMax, mathMin } from '../util/number'; import { SCALE_EXTENT_KIND_MAPPING } from '../scale/scaleMapper'; +import { AxisStatKey, eachAxisStatKey } from './axisStatistics'; /** @@ -88,7 +90,7 @@ export type ScaleExtentFixMinMax = boolean[]; type ScaleRawExtentResultForContainShape = Pick< ScaleRawExtentInternal, - 'noZoomEffMM' | 'containShapeCfg' + 'noZoomEffMM' | 'containShape' >; /** @@ -118,6 +120,12 @@ type ScaleRawExtentResultFinal = Pick< mapMM: number[]; }; +type ScaleRawExtentResultOthers = Pick< + ScaleRawExtentInternal, + 'startValue' | 'dataMM' +>; + + /** * CAVEAT: MUST NOT be modified outside! */ @@ -148,6 +156,8 @@ interface ScaleRawExtentInternal { // It indicates that the min max have been fixed by `dataZoom` when its start/end is not 0%/100%. zoomFixMM: ScaleExtentFixMinMax; + startValue: number; + // Mark that the axis should be blank. isBlank: boolean; @@ -155,15 +165,9 @@ interface ScaleRawExtentInternal { needToggleAxisInverse: boolean; - containShapeCfg: BoundaryGapOptionParsedItem['containShape'][]; + containShape: boolean; } -type BoundaryGapOptionParsedItem = { - value: number; - // From `NumericAxisBoundaryGapOptionItem['containShape']` - containShape: HashMap -}; - export type AxisContainShapeHandler = ( axis: Axis, scale: Scale, @@ -235,9 +239,10 @@ export class ScaleRawExtentInfo { // be the result that originalExtent enlarged by boundaryGap. // (3) If no data, it should be ensured that `scale.setBlank` is set. + let startValue = parseAxisModelMinMax(scale, model.get('startValue', true)); let modelMinRaw = model.get('min', true); if (modelMinRaw == null) { - modelMinRaw = model.get('startValue', true); + modelMinRaw = startValue; } if (modelMinRaw === 'dataMin') { noZoomEffMM[0] = dataMM[0]; @@ -272,7 +277,7 @@ export class ScaleRawExtentInfo { fixMM[1] = noZoomEffMM[1] != null; } - const boundaryGapCfg = parseBoundaryGapOption(scale, model); + const boundaryGap = parseBoundaryGapOption(scale, model); const span = !isOrdinal ? ((dataMM[1] - dataMM[0]) || Math.abs(dataMM[0])) @@ -282,12 +287,12 @@ export class ScaleRawExtentInfo { if (noZoomEffMM[0] == null) { noZoomEffMM[0] = isOrdinal ? (axisDataLen ? 0 : NaN) - : dataMM[0] - boundaryGapCfg[0].value * span; + : dataMM[0] - boundaryGap[0] * span; } if (noZoomEffMM[1] == null) { noZoomEffMM[1] = isOrdinal ? (axisDataLen ? axisDataLen - 1 : NaN) - : dataMM[1] + boundaryGapCfg[1].value * span; + : dataMM[1] + boundaryGap[1] * span; } // Normalize to `NaN` if invalid. @@ -318,6 +323,15 @@ export class ScaleRawExtentInfo { } } + if (scale.sanitize) { + startValue = scale.sanitize(startValue, dataMM); + } + + let containShape = (model as AxisBaseModel).get('containShape', true); + if (containShape == null) { + containShape = true; + } + const internal: ScaleRawExtentInternal = this._i = { scale, dataMM, @@ -325,13 +339,11 @@ export class ScaleRawExtentInfo { zoomMM: [], fixMM, zoomFixMM: [false, false], + startValue, isBlank, needCrossZero, needToggleAxisInverse, - containShapeCfg: [ - boundaryGapCfg[0].containShape, - boundaryGapCfg[1].containShape - ] + containShape, }; sanitizeExtent(internal, noZoomEffMM); @@ -341,11 +353,11 @@ export class ScaleRawExtentInfo { const internal = this._i; return { noZoomEffMM: internal.noZoomEffMM.slice(), - containShapeCfg: internal.containShapeCfg + containShape: internal.containShape }; } - makeForZoom(): ScaleRawExtentResultForZoom { + makeNoZoom(): ScaleRawExtentResultForZoom { const internal = this._i; return { noZoomEffMM: internal.noZoomEffMM.slice(), @@ -396,6 +408,14 @@ export class ScaleRawExtentInfo { return result; } + makeOthers(): ScaleRawExtentResultOthers { + const internal = this._i; + return { + dataMM: internal.dataMM.slice(), + startValue: internal.startValue, + }; + } + /** * NOTICE: * The caller must ensure `start <= end` and the range is equal or less then `noZoomMappingMinMax`. @@ -430,11 +450,16 @@ function makeNoZoomMappingMM(internal: ScaleRawExtentInternal): number[] { */ function sanitizeExtent( internal: ScaleRawExtentInternal, - mm: (number | NullUndefined)[] | NullUndefined + mm: (number | NullUndefined)[] ): void { const scale = internal.scale; - if (scale.sanitizeExtent && mm && isValidBoundsForExtent(mm[0], mm[1])) { - scale.sanitizeExtent(mm, internal.dataMM); + if (scale.sanitize) { + const dataMM = internal.dataMM; + mm[0] = scale.sanitize(mm[0], dataMM); + mm[1] = scale.sanitize(mm[1], dataMM); + if (mm[1] < mm[0]) { + mm[1] = mm[0]; + } } } @@ -447,7 +472,7 @@ function parseAxisModelMinMax(scale: Scale, minMax: ScaleDataValue): number { function parseBoundaryGapOption( scale: Scale, model: AxisBaseModel -): BoundaryGapOptionParsedItem[] { +): number[] { let boundaryGapOptionArr; if (isOrdinalScale(scale)) { boundaryGapOptionArr = [0, 0]; @@ -472,32 +497,12 @@ function parseBoundaryGapOption( } function parseBoundaryGapOptionItem( - opt: NumericAxisBoundaryGapOptionItemLoose | boolean -): BoundaryGapOptionParsedItem { - opt = typeof opt === 'boolean' ? 0 : opt; - const optItem = isObject(opt) ? opt : {value: opt} as NumericAxisBoundaryGapOptionItem; - const containShapeOption = optItem.containShape; - - let containShape: HashMap; - if (isObject(containShapeOption)) { - containShape = new HashMap(containShapeOption); - } - else { - containShape = new HashMap(); - if (containShapeOption === true) { - axisContainShapeHandlerMap.each(function (handler, seriesType) { - containShape.set(seriesType, true); - }); - } - else if (containShapeOption !== false) { // The defaults. - containShape.set('bar', true); - containShape.set('pictorialBar', true); - } - } - return { - value: parsePercent(optItem.value, 1) || 0, - containShape, - }; + opt: NumericAxisBoundaryGapOptionItemValue | boolean +): number { + return parsePercent( + typeof opt === 'boolean' ? 0 : opt, + 1 + ) || 0; } /** @@ -689,17 +694,22 @@ function injectScaleRawExtentInfo( scaleRawExtentInfo.from = from; } +/** + * See `axisSnippets.ts` for some commonly used handlers. + */ export function registerAxisContainShapeHandler( - seriesType: ComponentSubType, - handler: AxisContainShapeHandler + // `axisStatKey` is used to quickly omit irrelevant handlers, + // since handlers need to be iterated per axis. + axisStatKey: AxisStatKey, + handler: AxisContainShapeHandler, ) { if (__DEV__) { - assert(!axisContainShapeHandlerMap.get(seriesType)); + assert(!axisContainShapeHandlerMap.get(axisStatKey)); } - axisContainShapeHandlerMap.set(seriesType, handler); + axisContainShapeHandlerMap.set(axisStatKey, handler); } -const axisContainShapeHandlerMap: HashMap = new HashMap(); +const axisContainShapeHandlerMap: HashMap = createHashMap(); /** @@ -771,18 +781,19 @@ function calcContainShape( ): void { // `scale.getExtent` is required by AxisContainShapeHandler. See // `barGridCreateAxisContainShapeHandler` in `barGrid.ts` as an example. - const {noZoomEffMM, containShapeCfg} = rawExtentInfo.makeForContainShape(); + const {noZoomEffMM, containShape} = rawExtentInfo.makeForContainShape(); axis.scale.setExtent(noZoomEffMM[0], noZoomEffMM[1]); + if (!containShape) { + return; + } + // `NullUndefined` indicates that `linearSupplement` is not introduced. let linearSupplement: number[] | NullUndefined; - axisContainShapeHandlerMap.each(function (handler, seriesType) { - const containCfg = [ - containShapeCfg[0].get(seriesType), - containShapeCfg[1].get(seriesType) - ]; - if (containCfg[0] || containCfg[1]) { + eachAxisStatKey(axis, function (axisStatKey) { + const handler = axisContainShapeHandlerMap.get(axisStatKey); + if (handler) { const singleLinearSupplement = handler(axis, scale, ecModel); if (singleLinearSupplement) { linearSupplement = linearSupplement || [0, 0]; diff --git a/src/coord/single/prepareCustom.ts b/src/coord/single/prepareCustom.ts index 0ca8e46942..f8a4a23762 100644 --- a/src/coord/single/prepareCustom.ts +++ b/src/coord/single/prepareCustom.ts @@ -17,6 +17,7 @@ * under the License. */ +import { calcBandWidth } from '../axisBand'; import Single from './Single'; import { bind } from 'zrender/src/core/util'; @@ -26,7 +27,7 @@ function dataToCoordSize(this: Single, dataSize: number | number[], dataItem: nu const val = dataItem instanceof Array ? dataItem[0] : dataItem; const halfSize = (dataSize instanceof Array ? dataSize[0] : dataSize) / 2; return axis.type === 'category' - ? axis.getBandWidth() + ? calcBandWidth(axis).w : Math.abs(axis.dataToCoord(val - halfSize) - axis.dataToCoord(val + halfSize)); } diff --git a/src/core/CoordinateSystem.ts b/src/core/CoordinateSystem.ts index 1f247fe6f1..5073a56020 100644 --- a/src/core/CoordinateSystem.ts +++ b/src/core/CoordinateSystem.ts @@ -60,8 +60,6 @@ class CoordinateSystemManager { this._nonSeriesBoxMasterList = dealCreate(nonSeriesBoxCoordSysCreators, true); this._normalMasterList = dealCreate(normalCoordSysCreators, false); - performAxisStatistics && performAxisStatistics(ecModel); - function dealCreate(creatorMap: CoordinateSystemCreatorMap, canBeNonSeriesBox: boolean) { let coordinateSystems: CoordinateSystemMaster[] = []; zrUtil.each(creatorMap, function (creator, type) { @@ -359,10 +357,4 @@ export const simpleCoordSysInjectionProvider: CoordSysInjectionProvider = functi return coordSysModel && coordSysModel.coordinateSystem; }; -let performAxisStatistics: ((ecModel: GlobalModel) => void) | NullUndefined; -// To reduce code size, the implementation of `performAxisStatistics` is registered only when needed. -export function registerPerformAxisStatistics(impl: typeof performAxisStatistics): void { - performAxisStatistics = impl; -} - export default CoordinateSystemManager; diff --git a/src/core/echarts.ts b/src/core/echarts.ts index e67e49dd61..dc350f92cb 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -153,6 +153,8 @@ export const dependencies = { const TEST_FRAME_REMAIN_TIME = 1; const PRIORITY_PROCESSOR_SERIES_FILTER = 800; +// Axis statistics require filtered series. +const PRIORITY_PROCESSOR_AXIS_STATISTICS = 810; // In the current impl, "data stack" will modifies the original "series data extent". Some data // processors rely on the stack result dimension to calculate extents. So data stack // should be in front of other data processors. @@ -183,6 +185,7 @@ const PRIORITY_VISUAL_DECAL = 7000; export const PRIORITY = { PROCESSOR: { SERIES_FILTER: PRIORITY_PROCESSOR_SERIES_FILTER, + AXIS_STATISTICS: PRIORITY_PROCESSOR_AXIS_STATISTICS, FILTER: PRIORITY_PROCESSOR_FILTER, STATISTIC: PRIORITY_PROCESSOR_STATISTIC }, diff --git a/src/data/DataStore.ts b/src/data/DataStore.ts index c9b7f932c0..0d9e8836d3 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -24,7 +24,8 @@ import { NullUndefined, OptionDataItem, ParsedValue, - ParsedValueNumeric + ParsedValueNumeric, + UNDEFINED_STR } from '../util/types'; import { DataProvider } from './helper/dataProvider'; import { @@ -35,15 +36,14 @@ import { shouldRetrieveDataByName, Source } from './Source'; import { initExtentForUnion } from '../util/model'; import { asc } from '../util/number'; -const UNDEFINED = 'undefined'; /* global Float64Array, Int32Array, Uint32Array, Uint16Array */ // Caution: MUST not use `new CtorUint32Array(arr, 0, len)`, because the Ctor of array is // different from the Ctor of typed array. -export const CtorUint32Array = typeof Uint32Array === UNDEFINED ? Array : Uint32Array; -export const CtorUint16Array = typeof Uint16Array === UNDEFINED ? Array : Uint16Array; -export const CtorInt32Array = typeof Int32Array === UNDEFINED ? Array : Int32Array; -export const CtorFloat64Array = typeof Float64Array === UNDEFINED ? Array : Float64Array; +export const CtorUint32Array = typeof Uint32Array === UNDEFINED_STR ? Array : Uint32Array; +export const CtorUint16Array = typeof Uint16Array === UNDEFINED_STR ? Array : Uint16Array; +export const CtorInt32Array = typeof Int32Array === UNDEFINED_STR ? Array : Int32Array; +export const CtorFloat64Array = typeof Float64Array === UNDEFINED_STR ? Array : Float64Array; /** * Multi dimensional data store */ diff --git a/src/data/SeriesDimensionDefine.ts b/src/data/SeriesDimensionDefine.ts index 1ce09813a0..9d2376e395 100644 --- a/src/data/SeriesDimensionDefine.ts +++ b/src/data/SeriesDimensionDefine.ts @@ -51,8 +51,8 @@ class SeriesDimensionDefine { * 1. When there are too many dimensions in data store, seriesData only save the * used store dimensions. * 2. We use dimensionIndex but not name to reference store dimension - * becuause the dataset dimension definition might has no name specified by users, - * or names in sereis dimension definition might be different from dataset. + * because the dataset dimension definition might has no name specified by users, + * or names in series dimension definition might be different from dataset. */ storeDimIndex?: number; diff --git a/src/data/helper/dataStackHelper.ts b/src/data/helper/dataStackHelper.ts index 172d686b0d..f10f7af1d7 100644 --- a/src/data/helper/dataStackHelper.ts +++ b/src/data/helper/dataStackHelper.ts @@ -87,7 +87,7 @@ export function enableDataStack( store = dimensionsInput.store; } - // Compatibal: when `stack` is set as '', do not stack. + // compatible: when `stack` is set as '', do not stack. const mayStack = !!(seriesModel && seriesModel.get('stack')); let stackedByDimInfo: SeriesDimensionDefine; let stackedDimInfo: SeriesDimensionDefine; diff --git a/src/layout/barCommon.ts b/src/layout/barCommon.ts new file mode 100644 index 0000000000..3c9d7699cc --- /dev/null +++ b/src/layout/barCommon.ts @@ -0,0 +1,65 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { getMetricsMinGapOnNonCategoryAxis } from '../chart/helper/axisSnippets'; +import type Axis from '../coord/Axis'; +import { AxisStatKey, requireAxisStatistics } from '../coord/axisStatistics'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import { isNullableNumberFinite } from '../util/number'; + + +export type BaseBarSeriesSubType = 'bar' | 'pictorialBar'; + +export const BAR_SERIES_TYPE = 'bar'; + +export function registerAxisStatisticsForBaseBar( + registers: EChartsExtensionInstallRegisters, + axisStatKey: AxisStatKey, + seriesType: BaseBarSeriesSubType, + coordSysType: 'cartesian2d' | 'polar' +) { + requireAxisStatistics( + registers, + axisStatKey, + { + collectAxisSeries(ecModel, saveAxisSeries) { + // NOTICE: The order of series matters - must be respected to the declaration on ec option, + // because for historical reason, in `barGrid.ts`, the last series holds the effective ec option. + // (See `calcBarWidthAndOffset` in `barGrid.ts`). + ecModel.eachSeriesByType(seriesType, function (seriesModel) { + const coordSys = seriesModel.coordinateSystem; + if (coordSys && coordSys.type === coordSysType) { + saveAxisSeries(seriesModel.getBaseAxis(), seriesModel); + } + }); + }, + getMetrics: getMetricsMinGapOnNonCategoryAxis, + } + ); +} + +// See cases in `test/bar-start.html` and `#7412`, `#8747`. +export function getStartValue(baseAxis: Axis): number { + let startValue = baseAxis.scale.rawExtentInfo.makeOthers().startValue; + if (!isNullableNumberFinite(startValue)) { + startValue = 0; + } + return startValue; +} + diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index 9cd2c44b30..a1fa83b4aa 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -17,13 +17,13 @@ * under the License. */ -import { each, defaults, hasOwn } from 'zrender/src/core/util'; +import { each, defaults, hasOwn, assert } from 'zrender/src/core/util'; import { isNullableNumberFinite, mathAbs, mathMax, mathMin, parsePercent } from '../util/number'; import { isDimensionStacked } from '../data/helper/dataStackHelper'; import createRenderPlanner from '../chart/helper/createRenderPlanner'; import Axis2D from '../coord/cartesian/Axis2D'; import GlobalModel from '../model/Global'; -import type Cartesian2D from '../coord/cartesian/Cartesian2D'; +import Cartesian2D from '../coord/cartesian/Cartesian2D'; import { StageHandler, NullUndefined } from '../util/types'; import { createFloat32Array } from '../util/vendor'; import { @@ -43,20 +43,23 @@ import { } from '../coord/scaleRawExtentInfo'; import { EChartsExtensionInstallRegisters } from '../extension'; import { - AxisStatisticsClient, AxisStatisticsKey, eachCollectedAxis, - eachCollectedSeries, getCollectedSeriesLength, requireAxisStatistics + AxisStatKey, + eachCollectedAxis, + eachCollectedSeries, getCollectedSeriesLength } from '../coord/axisStatistics'; import { - AXIS_BAND_WIDTH_KIND_NORMAL, AxisBandWidthResult, calcBandWidth + AxisBandWidthResult, calcBandWidth } from '../coord/axisBand'; +import { BaseBarSeriesSubType, getStartValue, registerAxisStatisticsForBaseBar } from './barCommon'; +import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel'; const callOnlyOnce = makeCallOnlyOnce(); const STACK_PREFIX = '__ec_stack_'; -function getSeriesStackId(seriesModel: BaseBarSeriesModel): string { - return (seriesModel as BarSeriesModel).get('stack') || STACK_PREFIX + seriesModel.seriesIndex; +function getSeriesStackId(seriesModel: BaseBarSeriesModel): StackId { + return ((seriesModel as BarSeriesModel).get('stack') || STACK_PREFIX + seriesModel.seriesIndex) as StackId; } interface BarGridLayoutAxisInfo { @@ -72,12 +75,11 @@ interface BarGridLayoutAxisSeriesInfo { barGap: number | string defaultBarGap?: number | string barCategoryGap: number | string - stackId: string + stackId: StackId } -type StackId = string; +type StackId = string & {_: 'barGridStackId'}; -export type BaseBarSeriesSubType = 'bar' | 'pictorialBar'; export interface BarGridLayoutOptionForCustomSeries { count: number @@ -101,7 +103,7 @@ export type BarGridColumnLayoutOnAxis = BarGridLayoutAxisInfo & { }; type BarGridLayoutResultItemInternal = { - bandWidth: BarGridLayoutAxisInfo['bandWidthResult']['bandWidth'] + bandWidth: BarGridLayoutAxisInfo['bandWidthResult']['w'] offset: number // An offset with respect to `dataToPoint` width: number }; @@ -124,13 +126,12 @@ export function computeBarLayoutForCustomSeries(opt: BarGridLayoutOption): BarGr return; } - const params: BarGridLayoutAxisSeriesInfo[] = []; - const bandWidthResult: AxisBandWidthResult = {}; - calcBandWidth(bandWidthResult, opt.axis, false); + const bandWidthResult = calcBandWidth(opt.axis); + const params: BarGridLayoutAxisSeriesInfo[] = []; for (let i = 0; i < opt.count || 0; i++) { params.push(defaults({ - stackId: STACK_PREFIX + i + stackId: STACK_PREFIX + i as StackId }, opt) as BarGridLayoutAxisSeriesInfo); } const widthAndOffsets = calcBarWidthAndOffset({ @@ -140,7 +141,7 @@ export function computeBarLayoutForCustomSeries(opt: BarGridLayoutOption): BarGr const result: BarGridLayoutResultItem[] = []; for (let i = 0; i < opt.count; i++) { - const item = widthAndOffsets[STACK_PREFIX + i] as BarGridLayoutResultItem; + const item = widthAndOffsets[STACK_PREFIX + i as StackId] as BarGridLayoutResultItem; item.offsetCenter = item.offset + item.width / 2; result.push(item); } @@ -171,11 +172,13 @@ function createLayoutInfoListOnAxis( ): BarGridLayoutAxisInfo { const seriesInfoOnAxis: BarGridLayoutAxisSeriesInfo[] = []; - const bandWidthResult: AxisBandWidthResult = {}; - calcBandWidth(bandWidthResult, baseAxis, false); - const bandWidth = bandWidthResult.bandWidth; + const bandWidthResult = calcBandWidth( + baseAxis, + {fromStat: {key: makeAxisStatKey(seriesType)}, min: 1} + ); + const bandWidth = bandWidthResult.w; - eachCollectedSeries(baseAxis, axisStatKey(seriesType), function (seriesModel: BaseBarSeriesModel) { + eachCollectedSeries(baseAxis, makeAxisStatKey(seriesType), function (seriesModel: BaseBarSeriesModel) { seriesInfoOnAxis.push({ barWidth: parsePercent(seriesModel.get('barWidth'), bandWidth), barMaxWidth: parsePercent(seriesModel.get('barMaxWidth'), bandWidth), @@ -213,7 +216,7 @@ function calcBarWidthAndOffset( minWidth?: number } - const bandWidth = seriesInfoOnAxis.bandWidthResult.bandWidth; + const bandWidth = seriesInfoOnAxis.bandWidthResult.w; let remainedWidth = bandWidth; let autoWidthCount: number = 0; let barCategoryGapOption: number | string; @@ -349,11 +352,15 @@ function calcBarWidthAndOffset( return result; } - export function layout(seriesType: BaseBarSeriesSubType, ecModel: GlobalModel): void { - eachCollectedAxis(ecModel, function (axis) { - const columnLayout = makeColumnLayoutOnAxisReal(axis as Axis2D, seriesType); - eachCollectedSeries(axis, axisStatKey(seriesType), function (seriesModel) { + const axisStatKey = makeAxisStatKey(seriesType); + eachCollectedAxis(ecModel, axisStatKey, function (axis: Axis2D) { + if (__DEV__) { + assert(axis instanceof Axis2D); + } + const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); + + eachCollectedSeries(axis, axisStatKey, function (seriesModel) { const columnLayoutInfo = columnLayout.columnMap[getSeriesStackId(seriesModel)]; seriesModel.getData().setLayout({ bandWidth: columnLayoutInfo.bandWidth, @@ -361,6 +368,7 @@ export function layout(seriesType: BaseBarSeriesSubType, ecModel: GlobalModel): size: columnLayoutInfo.width }); }); + }); } @@ -388,7 +396,9 @@ export function createProgressiveLayout(seriesType: string): StageHandler { const stackResultDim = data.getCalculationInfo('stackResultDimension'); const stacked = isDimensionStacked(data, valueDim) && !!data.getCalculationInfo('stackedOnSeries'); const isValueAxisH = valueAxis.isHorizontal(); - const valueAxisStart = getValueAxisStart(baseAxis, valueAxis); + + const valueAxisStart = valueAxis.toGlobalCoord(valueAxis.dataToCoord(getStartValue(baseAxis))); + const isLarge = isInLargeMode(seriesModel); const barMinHeight = seriesModel.get('barMinHeight') || 0; @@ -432,8 +442,7 @@ export function createProgressiveLayout(seriesType: string): StageHandler { if (isValueAxisH) { const coord = cartesian.dataToPoint([value, baseValue]); if (stacked) { - const startCoord = cartesian.dataToPoint([stackStartValue, baseValue]); - baseCoord = startCoord[0]; + baseCoord = cartesian.dataToPoint([stackStartValue, baseValue])[0]; } x = baseCoord; y = coord[1] + columnOffset; @@ -447,8 +456,7 @@ export function createProgressiveLayout(seriesType: string): StageHandler { else { const coord = cartesian.dataToPoint([baseValue, value]); if (stacked) { - const startCoord = cartesian.dataToPoint([baseValue, stackStartValue]); - baseCoord = startCoord[1]; + baseCoord = cartesian.dataToPoint([baseValue, stackStartValue])[1]; } x = coord[0] + columnOffset; y = baseCoord; @@ -499,19 +507,6 @@ function isInLargeMode(seriesModel: BaseBarSeriesModel) { return seriesModel.pipelineContext && seriesModel.pipelineContext.large; } -// See cases in `test/bar-start.html` and `#7412`, `#8747`. -function getValueAxisStart(baseAxis: Axis2D, valueAxis: Axis2D) { - let startValue = valueAxis.model.get('startValue'); - if (!startValue) { - startValue = 0; - } - return valueAxis.toGlobalCoord( - valueAxis.dataToCoord( - valueAxis.type === 'log' - ? (startValue > 0 ? startValue : 1) - : startValue)); -} - /** * NOTICE: @@ -521,13 +516,11 @@ function getValueAxisStart(baseAxis: Axis2D, valueAxis: Axis2D) { * See the summary of the process of extent determination in the comment of `scaleMapper.setExtent`. */ function barGridCreateAxisContainShapeHandler(seriesType: BaseBarSeriesSubType): AxisContainShapeHandler { - return function ( - axis, scale, ecModel - ) { + return function (axis, scale, ecModel) { // If bars are placed on 'time', 'value', 'log' axis, handle bars overflow here. // See #6728, #4862, `test/bar-overflow-time-plot.html` if (axis && axis instanceof Axis2D && !isOrdinalScale(scale)) { - if (!getCollectedSeriesLength(axis, axisStatKey(seriesType))) { + if (!getCollectedSeriesLength(axis, makeAxisStatKey(seriesType))) { return; // Quick path - in most cases there is no bar on non-ordinal axis. } const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); @@ -543,9 +536,9 @@ function calcShapeOverflowSupplement( return; } const bandWidthResult = columnLayout.bandWidthResult; - const bandWidthResultKind = bandWidthResult.kind; - if (bandWidthResultKind == null) { - return; // No series data. + const invRatio = (bandWidthResult.fromStat || {}).invRatio; + if (!isNullableNumberFinite(invRatio)) { + return; // No series data or no more than one distinct valid data values. } // The calculation below is based on a proportion mapping from @@ -557,7 +550,7 @@ function calcShapeOverflowSupplement( // (Note: `|---|` above represents "pixels" rather than "data".) const barsBoundPx = initExtentForUnion(); - const bandWidth = bandWidthResult.bandWidth; + const bandWidth = bandWidthResult.w; // Union `-bandWidth / 2` and `bandWidth / 2` to provide extra space for visually preferred, // Otherwise the bars on the edges may overlap with axis line. // And it also includes `0`, which ensures `barsBoundPx[0] <= 0 <= barsBoundPx[1]`. @@ -570,55 +563,33 @@ function calcShapeOverflowSupplement( unionExtentFromNumber(barsBoundPx, item.offset + item.width); }); - const ratio = bandWidthResult.ratio; - if (extentHasValue(barsBoundPx) && isNullableNumberFinite(ratio) - && bandWidthResultKind === AXIS_BAND_WIDTH_KIND_NORMAL - ) { + if (extentHasValue(barsBoundPx)) { // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on // `minGap` and extent on data domain. - return [barsBoundPx[0] * ratio, barsBoundPx[1] * ratio]; + return [barsBoundPx[0] * invRatio, barsBoundPx[1] * invRatio]; // If AXIS_BAND_WIDTH_KIND_SINGULAR, extent expansion is not needed. } } -function createAxisStatisticsClient(seriesType: BaseBarSeriesSubType): AxisStatisticsClient { - return { - /** - * NOTICE: - * The order of series matters - must be respected to the declaration on ec option, - * because for historical reason, the last series holds the effective ec option. - * See `calcBarWidthAndOffset`. - */ - collectAxisSeries(ecModel, saveAxisSeries) { - ecModel.eachSeriesByType(seriesType, function (seriesModel: BaseBarSeriesModel) { - if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { - saveAxisSeries( - (seriesModel.coordinateSystem as Cartesian2D).getBaseAxis(), - seriesModel - ); - } - }); - }, - - getMetrics(axis) { - return { - minGap: !isOrdinalScale(axis.scale) - }; - } - - }; -} - -function axisStatKey(seriesType: BaseBarSeriesSubType): AxisStatisticsKey { - return `barGrid-${seriesType}` as AxisStatisticsKey; +function makeAxisStatKey(seriesType: BaseBarSeriesSubType): AxisStatKey { + return `barGrid-${seriesType}` as AxisStatKey; } export function registerBarGridAxisHandlers(registers: EChartsExtensionInstallRegisters) { callOnlyOnce(registers, function () { function register(seriesType: BaseBarSeriesSubType): void { - requireAxisStatistics(axisStatKey(seriesType), createAxisStatisticsClient(seriesType)); - registerAxisContainShapeHandler(seriesType, barGridCreateAxisContainShapeHandler(seriesType)); + const axisStatKey = makeAxisStatKey(seriesType); + registerAxisStatisticsForBaseBar( + registers, + axisStatKey, + seriesType, + COORD_SYS_TYPE_CARTESIAN_2D + ); + registerAxisContainShapeHandler( + axisStatKey, + barGridCreateAxisContainShapeHandler(seriesType) + ); } register('bar'); diff --git a/src/layout/barPolar.ts b/src/layout/barPolar.ts index 6e1d054fe0..357e24c6f2 100644 --- a/src/layout/barPolar.ts +++ b/src/layout/barPolar.ts @@ -17,8 +17,7 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; -import {parsePercent} from '../util/number'; +import {mathAbs, mathMax, mathMin, mathPI, parsePercent} from '../util/number'; import {isDimensionStacked} from '../data/helper/dataStackHelper'; import type BarSeriesModel from '../chart/bar/BarSeries'; import type Polar from '../coord/polar/Polar'; @@ -27,7 +26,19 @@ import RadiusAxis from '../coord/polar/RadiusAxis'; import GlobalModel from '../model/Global'; import ExtensionAPI from '../core/ExtensionAPI'; import { Dictionary } from '../util/types'; -import { PolarAxisModel } from '../coord/polar/AxisModel'; +import { calcBandWidth } from '../coord/axisBand'; +import { createBandWidthBasedAxisContainShapeHandler } from '../chart/helper/axisSnippets'; +import { makeCallOnlyOnce } from '../util/model'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import { registerAxisContainShapeHandler } from '../coord/scaleRawExtentInfo'; +import { getStartValue, registerAxisStatisticsForBaseBar } from './barCommon'; +import { AxisStatKey, eachCollectedAxis, eachCollectedSeries } from '../coord/axisStatistics'; +import { COORD_SYS_TYPE_POLAR } from '../coord/polar/PolarModel'; +import type Axis from '../coord/Axis'; +import { assert, each } from 'zrender/src/core/util'; + + +const callOnlyOnce = makeCallOnlyOnce(); type PolarAxis = AngleAxis | RadiusAxis; @@ -35,209 +46,185 @@ interface StackInfo { width: number maxWidth: number } -interface LayoutColumnInfo { - autoWidthCount: number - bandWidth: number - remainedWidth: number - categoryGap: string | number - gap: string | number - stacks: Dictionary -} interface BarWidthAndOffset { width: number offset: number } +type StackId = string; + +type BarWidthAndOffsetOnAxis = Record; + +type LastStackCoords = Record; + function getSeriesStackId(seriesModel: BarSeriesModel) { return seriesModel.get('stack') || '__ec_stack_' + seriesModel.seriesIndex; } -function getAxisKey(polar: Polar, axis: PolarAxis) { - return axis.dim + polar.model.componentIndex; -} +export function barLayoutPolar(seriesType: 'bar', ecModel: GlobalModel, api: ExtensionAPI) { + const axisStatKey = makeAxisStatKey(seriesType); -function barLayoutPolar(seriesType: string, ecModel: GlobalModel, api: ExtensionAPI) { + eachCollectedAxis(ecModel, axisStatKey, function (axis: PolarAxis) { + if (__DEV__) { + assert((axis instanceof AngleAxis) || axis instanceof RadiusAxis); + } - const lastStackCoords: Dictionary<{p: number, n: number}[]> = {}; + const barWidthAndOffset = calcRadialBar(axis, seriesType); - const barWidthAndOffset = calRadialBar( - zrUtil.filter( - ecModel.getSeriesByType(seriesType) as BarSeriesModel[], - function (seriesModel) { - return !ecModel.isSeriesFiltered(seriesModel) - && seriesModel.coordinateSystem - && seriesModel.coordinateSystem.type === 'polar'; - } - ) - ); - - ecModel.eachSeriesByType(seriesType, function (seriesModel: BarSeriesModel) { + const lastStackCoords: LastStackCoords = {}; + eachCollectedSeries(axis, axisStatKey, function (seriesModel: BarSeriesModel) { + layoutPerAxisPerSeries(axis, seriesModel, barWidthAndOffset, lastStackCoords); + }); + }); +} - // Check series coordinate, do layout for polar only - if (seriesModel.coordinateSystem.type !== 'polar') { - return; +function layoutPerAxisPerSeries( + baseAxis: PolarAxis, + seriesModel: BarSeriesModel, + barWidthAndOffset: BarWidthAndOffsetOnAxis, + lastStackCoords: LastStackCoords +) { + const data = seriesModel.getData(); + const stackId = getSeriesStackId(seriesModel); + const columnLayoutInfo = barWidthAndOffset[stackId]; + const columnOffset = columnLayoutInfo.offset; + const columnWidth = columnLayoutInfo.width; + const polar = seriesModel.coordinateSystem as Polar; + if (__DEV__) { + assert(polar.type === 'polar'); + } + const valueAxis = polar.getOtherAxis(baseAxis); + + const cx = polar.cx; + const cy = polar.cy; + + const barMinHeight = seriesModel.get('barMinHeight') || 0; + const barMinAngle = seriesModel.get('barMinAngle') || 0; + + lastStackCoords[stackId] = lastStackCoords[stackId] || []; + + const valueDim = data.mapDimension(valueAxis.dim); + const baseDim = data.mapDimension(baseAxis.dim); + const stacked = isDimensionStacked(data, valueDim /* , baseDim */); + const clampLayout = baseAxis.dim !== 'radius' + || !seriesModel.get('roundCap', true); + + const valueAxisStart = valueAxis.dataToCoord(getStartValue(baseAxis)); + + for (let idx = 0, len = data.count(); idx < len; idx++) { + const value = data.get(valueDim, idx) as number; + const baseValue = data.get(baseDim, idx) as number; + + const sign = value >= 0 ? 'p' : 'n' as 'p' | 'n'; + let baseCoord = valueAxisStart; + + // Because of the barMinHeight, we can not use the value in + // stackResultDimension directly. + if (stacked) { + // FIXME: follow the same logic in `barGrid.ts`: + // Use stackResultDimension, and lastStackCoords is not needed. + if (!lastStackCoords[stackId][baseValue]) { + lastStackCoords[stackId][baseValue] = { + p: valueAxisStart, // Positive stack + n: valueAxisStart // Negative stack + }; + } + // Should also consider #4243 + baseCoord = lastStackCoords[stackId][baseValue][sign]; } - const data = seriesModel.getData(); - const polar = seriesModel.coordinateSystem as Polar; - const baseAxis = polar.getBaseAxis(); - const axisKey = getAxisKey(polar, baseAxis); - - const stackId = getSeriesStackId(seriesModel); - const columnLayoutInfo = barWidthAndOffset[axisKey][stackId]; - const columnOffset = columnLayoutInfo.offset; - const columnWidth = columnLayoutInfo.width; - const valueAxis = polar.getOtherAxis(baseAxis); - - const cx = seriesModel.coordinateSystem.cx; - const cy = seriesModel.coordinateSystem.cy; - - const barMinHeight = seriesModel.get('barMinHeight') || 0; - const barMinAngle = seriesModel.get('barMinAngle') || 0; - - lastStackCoords[stackId] = lastStackCoords[stackId] || []; - - const valueDim = data.mapDimension(valueAxis.dim); - const baseDim = data.mapDimension(baseAxis.dim); - const stacked = isDimensionStacked(data, valueDim /* , baseDim */); - const clampLayout = baseAxis.dim !== 'radius' - || !seriesModel.get('roundCap', true); - - const valueAxisModel = valueAxis.model as PolarAxisModel; - const startValue = valueAxisModel.get('startValue'); - const valueAxisStart = valueAxis.dataToCoord(startValue || 0); - - for (let idx = 0, len = data.count(); idx < len; idx++) { - const value = data.get(valueDim, idx) as number; - const baseValue = data.get(baseDim, idx) as number; - - const sign = value >= 0 ? 'p' : 'n' as 'p' | 'n'; - let baseCoord = valueAxisStart; - - // Because of the barMinHeight, we can not use the value in - // stackResultDimension directly. - // Only ordinal axis can be stacked. - if (stacked) { - - if (!lastStackCoords[stackId][baseValue]) { - lastStackCoords[stackId][baseValue] = { - p: valueAxisStart, // Positive stack - n: valueAxisStart // Negative stack - }; - } - // Should also consider #4243 - baseCoord = lastStackCoords[stackId][baseValue][sign]; - } + let r0; + let r; + let startAngle; + let endAngle; - let r0; - let r; - let startAngle; - let endAngle; + // radial sector + if (valueAxis.dim === 'radius') { + let radiusSpan = valueAxis.dataToCoord(value) - valueAxisStart; + const angle = baseAxis.dataToCoord(baseValue); - // radial sector - if (valueAxis.dim === 'radius') { - let radiusSpan = valueAxis.dataToCoord(value) - valueAxisStart; - const angle = baseAxis.dataToCoord(baseValue); + if (mathAbs(radiusSpan) < barMinHeight) { + radiusSpan = (radiusSpan < 0 ? -1 : 1) * barMinHeight; + } - if (Math.abs(radiusSpan) < barMinHeight) { - radiusSpan = (radiusSpan < 0 ? -1 : 1) * barMinHeight; - } + r0 = baseCoord; + r = baseCoord + radiusSpan; + startAngle = angle - columnOffset; + endAngle = startAngle - columnWidth; - r0 = baseCoord; - r = baseCoord + radiusSpan; - startAngle = angle - columnOffset; - endAngle = startAngle - columnWidth; + stacked && (lastStackCoords[stackId][baseValue][sign] = r); + } + // tangential sector + else { + let angleSpan = valueAxis.dataToCoord(value, clampLayout) - valueAxisStart; + const radius = baseAxis.dataToCoord(baseValue); - stacked && (lastStackCoords[stackId][baseValue][sign] = r); - } - // tangential sector - else { - let angleSpan = valueAxis.dataToCoord(value, clampLayout) - valueAxisStart; - const radius = baseAxis.dataToCoord(baseValue); - - if (Math.abs(angleSpan) < barMinAngle) { - angleSpan = (angleSpan < 0 ? -1 : 1) * barMinAngle; - } - - r0 = radius + columnOffset; - r = r0 + columnWidth; - startAngle = baseCoord; - endAngle = baseCoord + angleSpan; - - // if the previous stack is at the end of the ring, - // add a round to differentiate it from origin - // let extent = angleAxis.getExtent(); - // let stackCoord = angle; - // if (stackCoord === extent[0] && value > 0) { - // stackCoord = extent[1]; - // } - // else if (stackCoord === extent[1] && value < 0) { - // stackCoord = extent[0]; - // } - stacked && (lastStackCoords[stackId][baseValue][sign] = endAngle); + if (mathAbs(angleSpan) < barMinAngle) { + angleSpan = (angleSpan < 0 ? -1 : 1) * barMinAngle; } - data.setItemLayout(idx, { - cx: cx, - cy: cy, - r0: r0, - r: r, - // Consider that positive angle is anti-clockwise, - // while positive radian of sector is clockwise - startAngle: -startAngle * Math.PI / 180, - endAngle: -endAngle * Math.PI / 180, - - /** - * Keep the same logic with bar in catesion: use end value to - * control direction. Notice that if clockwise is true (by - * default), the sector will always draw clockwisely, no matter - * whether endAngle is greater or less than startAngle. - */ - clockwise: startAngle >= endAngle - }); - + r0 = radius + columnOffset; + r = r0 + columnWidth; + startAngle = baseCoord; + endAngle = baseCoord + angleSpan; + + // if the previous stack is at the end of the ring, + // add a round to differentiate it from origin + // let extent = angleAxis.getExtent(); + // let stackCoord = angle; + // if (stackCoord === extent[0] && value > 0) { + // stackCoord = extent[1]; + // } + // else if (stackCoord === extent[1] && value < 0) { + // stackCoord = extent[0]; + // } + stacked && (lastStackCoords[stackId][baseValue][sign] = endAngle); } - }); + data.setItemLayout(idx, { + cx: cx, + cy: cy, + r0: r0, + r: r, + // Consider that positive angle is anti-clockwise, + // while positive radian of sector is clockwise + startAngle: -startAngle * mathPI / 180, + endAngle: -endAngle * mathPI / 180, + + /** + * Keep the same logic with bar in catesion: use end value to + * control direction. Notice that if clockwise is true (by + * default), the sector will always draw clockwisely, no matter + * whether endAngle is greater or less than startAngle. + */ + clockwise: startAngle >= endAngle + }); + } } /** * Calculate bar width and offset for radial bar charts */ -function calRadialBar(barSeries: BarSeriesModel[]) { - // Columns info on each category axis. Key is polar name - const columnsMap: Dictionary = {}; - - zrUtil.each(barSeries, function (seriesModel, idx) { - const data = seriesModel.getData(); - const polar = seriesModel.coordinateSystem as Polar; - - const baseAxis = polar.getBaseAxis(); - const axisKey = getAxisKey(polar, baseAxis); - - const axisExtent = baseAxis.getExtent(); - const bandWidth = baseAxis.type === 'category' - ? baseAxis.getBandWidth() - : (Math.abs(axisExtent[1] - axisExtent[0]) / data.count()); - - const columnsOnAxis = columnsMap[axisKey] || { - bandWidth: bandWidth, - remainedWidth: bandWidth, - autoWidthCount: 0, - categoryGap: '20%', - gap: '30%', - stacks: {} - }; - const stacks = columnsOnAxis.stacks; - columnsMap[axisKey] = columnsOnAxis; - +function calcRadialBar(axis: Axis, seriesType: 'bar'): BarWidthAndOffsetOnAxis { + const bandWidth = calcBandWidth( + axis, + {fromStat: {key: makeAxisStatKey(seriesType)}, min: 1} + ).w; + + let remainedWidth: number = bandWidth; + let autoWidthCount: number = 0; + let categoryGapOption: string | number = '20%'; + let gapOption: string | number = '30%'; + const stacks: Dictionary = {}; + + eachCollectedSeries(axis, makeAxisStatKey(seriesType), function (seriesModel: BarSeriesModel, idx) { const stackId = getSeriesStackId(seriesModel); if (!stacks[stackId]) { - columnsOnAxis.autoWidthCount++; + autoWidthCount++; } stacks[stackId] = stacks[stackId] || { width: 0, @@ -252,82 +239,94 @@ function calRadialBar(barSeries: BarSeriesModel[]) { seriesModel.get('barMaxWidth'), bandWidth ); - const barGap = seriesModel.get('barGap'); - const barCategoryGap = seriesModel.get('barCategoryGap'); + const barGapOption = seriesModel.get('barGap'); + const barCategoryGapOption = seriesModel.get('barCategoryGap'); if (barWidth && !stacks[stackId].width) { - barWidth = Math.min(columnsOnAxis.remainedWidth, barWidth); + barWidth = mathMin(remainedWidth, barWidth); stacks[stackId].width = barWidth; - columnsOnAxis.remainedWidth -= barWidth; + remainedWidth -= barWidth; } barMaxWidth && (stacks[stackId].maxWidth = barMaxWidth); - (barGap != null) && (columnsOnAxis.gap = barGap); - (barCategoryGap != null) && (columnsOnAxis.categoryGap = barCategoryGap); + // For historical design, use the last series declared that. + (barGapOption != null) && (gapOption = barGapOption); + (barCategoryGapOption != null) && (categoryGapOption = barCategoryGapOption); }); + const result: BarWidthAndOffsetOnAxis = {}; - const result: Dictionary> = {}; - - zrUtil.each(columnsMap, function (columnsOnAxis, coordSysName) { - - result[coordSysName] = {}; - - const stacks = columnsOnAxis.stacks; - const bandWidth = columnsOnAxis.bandWidth; - const categoryGap = parsePercent(columnsOnAxis.categoryGap, bandWidth); - const barGapPercent = parsePercent(columnsOnAxis.gap, 1); + const categoryGap = parsePercent(categoryGapOption, bandWidth); + const barGapPercent = parsePercent(gapOption, 1); - let remainedWidth = columnsOnAxis.remainedWidth; - let autoWidthCount = columnsOnAxis.autoWidthCount; - let autoWidth = (remainedWidth - categoryGap) - / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); - autoWidth = Math.max(autoWidth, 0); + let autoWidth = (remainedWidth - categoryGap) + / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + autoWidth = mathMax(autoWidth, 0); - // Find if any auto calculated bar exceeded maxBarWidth - zrUtil.each(stacks, function (column, stack) { - let maxWidth = column.maxWidth; - if (maxWidth && maxWidth < autoWidth) { - maxWidth = Math.min(maxWidth, remainedWidth); - if (column.width) { - maxWidth = Math.min(maxWidth, column.width); - } - remainedWidth -= maxWidth; - column.width = maxWidth; - autoWidthCount--; + // Find if any auto calculated bar exceeded maxBarWidth + each(stacks, function (column, stack) { + let maxWidth = column.maxWidth; + if (maxWidth && maxWidth < autoWidth) { + maxWidth = mathMin(maxWidth, remainedWidth); + if (column.width) { + maxWidth = mathMin(maxWidth, column.width); } - }); + remainedWidth -= maxWidth; + column.width = maxWidth; + autoWidthCount--; + } + }); - // Recalculate width again - autoWidth = (remainedWidth - categoryGap) - / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); - autoWidth = Math.max(autoWidth, 0); + // Recalculate width again + autoWidth = (remainedWidth - categoryGap) + / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + autoWidth = mathMax(autoWidth, 0); - let widthSum = 0; - let lastColumn: StackInfo; - zrUtil.each(stacks, function (column, idx) { - if (!column.width) { - column.width = autoWidth; - } - lastColumn = column; - widthSum += column.width * (1 + barGapPercent); - }); - if (lastColumn) { - widthSum -= lastColumn.width * barGapPercent; + let widthSum = 0; + let lastColumn: StackInfo; + each(stacks, function (column, idx) { + if (!column.width) { + column.width = autoWidth; } + lastColumn = column; + widthSum += column.width * (1 + barGapPercent); + }); + if (lastColumn) { + widthSum -= lastColumn.width * barGapPercent; + } + + let offset = -widthSum / 2; + each(stacks, function (column, stackId) { + result[stackId] = result[stackId] || { + offset: offset, + width: column.width + }; - let offset = -widthSum / 2; - zrUtil.each(stacks, function (column, stackId) { - result[coordSysName][stackId] = result[coordSysName][stackId] || { - offset: offset, - width: column.width - } as BarWidthAndOffset; - - offset += column.width * (1 + barGapPercent); - }); + offset += column.width * (1 + barGapPercent); }); return result; } -export default barLayoutPolar; \ No newline at end of file +function makeAxisStatKey(seriesType: 'bar'): AxisStatKey { + return `barPolar-${seriesType}` as AxisStatKey; +} + +export function registerBarPolarAxisHandlers( + registers: EChartsExtensionInstallRegisters, + seriesType: 'bar' // Currently only 'bar' is supported. +): void { + callOnlyOnce(registers, function () { + const axisStatKey = makeAxisStatKey(seriesType); + registerAxisStatisticsForBaseBar( + registers, + axisStatKey, + seriesType, + COORD_SYS_TYPE_POLAR + ); + registerAxisContainShapeHandler( + axisStatKey, + createBandWidthBasedAxisContainShapeHandler(axisStatKey) + ); + }); +} diff --git a/src/scale/Log.ts b/src/scale/Log.ts index 8adff48efa..e0d1e1c666 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -38,7 +38,8 @@ import { ScaleMapperTransformOutOpt } from './scaleMapper'; import { map } from 'zrender/src/core/util'; -import { isValidBoundsForExtent } from '../util/model'; +import { isValidBoundsForExtent, isValidNumberForExtent } from '../util/model'; +import { isNullableNumberFinite } from '../util/number'; type LogScaleSetting = { @@ -236,14 +237,15 @@ class LogScale extends Scale { return {g: 0}; }, - sanitizeExtent(extent, dataExtent) { - if (isValidBoundsForExtent(extent[0], extent[1]) - && isValidBoundsForExtent(dataExtent[0], dataExtent[1]) + sanitize(value, dataExtent) { + // Conservative - if dataExtent is invalid, do not sanitize. + if (isValidBoundsForExtent(dataExtent[0], dataExtent[1]) + && isNullableNumberFinite(value) ) { // `DataStore` has ensured that `dataExtent` is valid for LogScale. - extent[0] <= 0 && (extent[0] = dataExtent[0]); - extent[1] <= 0 && (extent[1] = dataExtent[0]); + value <= 0 && (value = dataExtent[0]); } + return value; }, getExtent() { diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts index 08da294e9f..76dd2e8719 100644 --- a/src/scale/scaleMapper.ts +++ b/src/scale/scaleMapper.ts @@ -43,7 +43,7 @@ import { DataSanitizationFilter } from '../data/helper/dataValueHelper'; * - `SCALE_EXTENT_KIND_MAPPING`: * It is an expanded extent from the start and end of `SCALE_EXTENT_KIND_EFFECTIVE`. In the * expanded parts, axis ticks and labels are considered meaningless and are not rendered. They - * can be typically created by `boundaryGap[i].containShape` feature. In this case, we need to: + * can be typically created by `xxxAxis.containShape` feature. In this case, we need to: * - Prevent "nice strategy" from triggering unexpectedly by the "contain shape expansion". * Otherwise, for example, the original extent is `[0, 1000]`, then the expanded * extent, say `[-5, 1000]`, can cause a considerable negative expansion by "nice", @@ -83,7 +83,7 @@ const SCALE_MAPPER_METHOD_NAMES_MAP: Record = { setExtent: 1, setExtent2: 1, getFilter: 1, - sanitizeExtent: 1, + sanitize: 1, freeze: 1, }; const SCALE_MAPPER_METHOD_NAMES = keys(SCALE_MAPPER_METHOD_NAMES_MAP); @@ -253,12 +253,16 @@ export interface ScaleMapperGeneric { getFilter?: () => DataSanitizationFilter; /** - * Sanitize the input extent if possible. For example, for LogScale, the negative part will be clampped. - * This provides some permissiveness to ec option like `xxxAxis.min/max`. + * NOTICE: + * Should not sanitize invalid values (e.g., NaN, Infinity, null, undefined), + * since it probably has special meaning, and always properly handled in every Scale. * - * The input `extent` can be modified. + * Sanitize the value if possible. For example, for LogScale, the negative part will be clampped. + * This provides some permissiveness to ec option like `xxxAxis.min/max`. */ - sanitizeExtent?: ((this: This, extent: number[], dataExtent: number[]) => void) | NullUndefined; + sanitize?: ( + (this: This, values: number | NullUndefined, dataExtent: number[]) => number | NullUndefined + ) | NullUndefined; /** * Restrict the modification behavior of a scale for robustness. e.g., avoid subsequently diff --git a/src/util/jitter.ts b/src/util/jitter.ts index 9953e6c865..36bbda5c0a 100644 --- a/src/util/jitter.ts +++ b/src/util/jitter.ts @@ -18,10 +18,12 @@ */ import type Axis from '../coord/Axis'; +import { calcBandWidth } from '../coord/axisBand'; import type { AxisBaseModel } from '../coord/AxisBaseModel'; import Axis2D from '../coord/cartesian/Axis2D'; import type SingleAxis from '../coord/single/SingleAxis'; import type SeriesModel from '../model/Series'; +import { isOrdinalScale } from '../scale/helper'; import { makeInner } from './model'; export function needFixJitter(seriesModel: SeriesModel, axis: Axis): boolean { @@ -73,8 +75,8 @@ export function fixJitter( 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() + const bandWidth = isOrdinalScale(fixedAxis.scale) + ? calcBandWidth(fixedAxis).w : null; if (jitterOverlap) { return fixJitterIgnoreOverlaps(floatCoord, jitter, bandWidth, radius); @@ -117,8 +119,8 @@ function fixJitterAvoidOverlaps( const minFloat = Math.abs(overlapA - floatCoord) < Math.abs(overlapB - floatCoord) ? overlapA : overlapB; // Clamp only category axis - const bandWidth = fixedAxis.scale.type === 'ordinal' - ? fixedAxis.getBandWidth() + const bandWidth = isOrdinalScale(fixedAxis.scale) + ? calcBandWidth(fixedAxis).w : null; const distance = Math.abs(minFloat - floatCoord); diff --git a/src/util/model.ts b/src/util/model.ts index 54b42de241..5412ee0584 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -29,7 +29,8 @@ import { indexOf, isStringSafe, isNumber, - hasOwn + hasOwn, + isTypedArray, } from 'zrender/src/core/util'; import env from 'zrender/src/core/env'; import GlobalModel from '../model/Global'; @@ -48,12 +49,13 @@ import { OptionName, InterpolatableValue, NullUndefined, + UNDEFINED_STR, } from './types'; import { Dictionary } from 'zrender/src/core/types'; import SeriesModel from '../model/Series'; import CartesianAxisModel from '../coord/cartesian/AxisModel'; import type GridModel from '../coord/cartesian/GridModel'; -import { isNumeric, getRandomIdBase, getPrecision, round } from './number'; +import { isNumeric, getRandomIdBase, getPrecision, round, MAX_SAFE_INTEGER } from './number'; import { error, warn } from './log'; import type Model from '../model/Model'; @@ -1251,17 +1253,20 @@ export function extentHasValue(extent: number[]): boolean { * A util for ensuring the callback is called only once. * @usage * const callOnlyOnce = makeCallOnlyOnce(); // Should be static (ESM top level). - * function someFunc(hostObj) { - * callOnlyOnce(hostObj, function () { - * // Do something immediately and only once for hostObj. + * function someFunc(registers: EChartsExtensionInstallRegisters): void { + * callOnlyOnce(registers, function () { + * // Do something immediately and only once per registers. * } * } */ export function makeCallOnlyOnce() { - const key = '__ec_once_' + onceUniqueIndex++; + const hiddenKey = '__ec_once_' + onceUniqueIndex++; return function (hostObj: Host, cb: () => void) { - if (!hasOwn(hostObj, key)) { - (hostObj as any)[key] = 1; + if (__DEV__) { + assert(hostObj); + } + if (!hasOwn(hostObj, hiddenKey)) { + (hostObj as any)[hiddenKey] = 1; cb(); } }; @@ -1293,12 +1298,9 @@ export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { /** * The cache is auto cleared at the begining of a run of "ec prepare". + * Typically, `setOption` trigger "ec prepare", but `dispatchAction` does not. * * NOTICE: - * - The cache can only be written at the "ec prepare" stage, such as - * - It can be written in `getTargetSeries` methods of data processors. - * - It can be written in `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. - * - The cache can be read in any stages. * - "ec prepare" is not necessarily performed before each "ec full update" performing. */ export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { @@ -1308,9 +1310,13 @@ export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerE /** * The cache is auto cleared at the begining of a run of "ec full update". * However, all shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. + * Typically, all `setOption` and some `dispatchAction` trigger "ec full update". + * This is the same as the lifecycle of coordinate systems instances and axes instances. * * NOTICE: - * - The cache can only be written AFTER "ec prepare" stage (not included). + * - The cache should NOT be written in: + * - `getTargetSeries` methods of data processors. + * - `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. * See `getCachePerECPrepare` for details. */ export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { diff --git a/src/util/number.ts b/src/util/number.ts index 13df66639e..4c75782f4a 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -441,7 +441,7 @@ export function addSafe(val0: number, val1: number): number { } // Number.MAX_SAFE_INTEGER, ie do not support. -export const MAX_SAFE_INTEGER = 9007199254740991; +export const MAX_SAFE_INTEGER = mathPow(2, 53) - 1; /** * To 0 - 2 * PI, considering negative radian. diff --git a/src/util/types.ts b/src/util/types.ts index 5c87cce9b3..ad6e962ad0 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -67,6 +67,7 @@ export type RendererType = 'canvas' | 'svg'; * which has to be determined by the implementation. */ export type NullUndefined = null | undefined; +export const UNDEFINED_STR = 'undefined'; export type LayoutOrient = 'vertical' | 'horizontal'; export type HorizontalAlign = 'left' | 'center' | 'right'; diff --git a/src/util/vendor.ts b/src/util/vendor.ts index ab4db32d39..8be2f5c8bc 100644 --- a/src/util/vendor.ts +++ b/src/util/vendor.ts @@ -17,18 +17,118 @@ * under the License. */ -import { isArray } from 'zrender/src/core/util'; +import { assert } from 'zrender/src/core/util'; +import { error } from './log'; +import { UNDEFINED_STR } from './types'; +import { MAX_SAFE_INTEGER } from './number'; -/* global Float32Array */ -const supportFloat32Array = typeof Float32Array !== 'undefined'; +/* global Int8Array, Int16Array, Int32Array, Uint8Array, Uint16Array, Uint32Array, + Uint8ClampedArray, Float32Array, Float64Array */ -const Float32ArrayCtor = !supportFloat32Array ? Array : Float32Array; +export const Int8ArrayCtor = typeof Int8Array !== UNDEFINED_STR ? Int8Array : undefined; +export const Int16ArrayCtor = typeof Int16Array !== UNDEFINED_STR ? Int16Array : undefined; +export const Int32ArrayCtor = typeof Int32Array !== UNDEFINED_STR ? Int32Array : undefined; +export const Uint8ArrayCtor = typeof Uint8Array !== UNDEFINED_STR ? Uint8Array : undefined; +export const Uint16ArrayCtor = typeof Uint16Array !== UNDEFINED_STR ? Uint16Array : undefined; +export const Uint32ArrayCtor = typeof Uint32Array !== UNDEFINED_STR ? Uint32Array : undefined; +export const Uint8ClampedArrayCtor = typeof Uint8ClampedArray !== UNDEFINED_STR ? Uint8ClampedArray : undefined; +export const Float32ArrayCtor = typeof Float32Array !== UNDEFINED_STR ? Float32Array : undefined; +export const Float64ArrayCtor = typeof Float64Array !== UNDEFINED_STR ? Float64Array : undefined; -export function createFloat32Array(arg: number | number[]): number[] | Float32Array { - if (isArray(arg)) { - // Return self directly if don't support TypedArray. - return supportFloat32Array ? new Float32Array(arg) : arg; +// PENDING: `BigInt64Array` `BigUint64Array` is not suppored yet. +export type TypedArrayCtor = + typeof Int8ArrayCtor + | typeof Int16ArrayCtor + | typeof Int32ArrayCtor + | typeof Uint8ArrayCtor + | typeof Uint16ArrayCtor + | typeof Uint32ArrayCtor + | typeof Uint8ClampedArrayCtor + | typeof Float32ArrayCtor + | typeof Float64ArrayCtor; + +export type TypedArrayType = + Int8Array + | Int16Array + | Int32Array + | Uint8Array + | Uint16Array + | Uint32Array + | Uint8ClampedArray + | Float32Array + | Float64Array; + + +export function createFloat32Array(capacity: number): number[] | Float32Array { + return tryEnsureTypedArray({ctor: Float32ArrayCtor}, capacity).arr as number[] | Float32Array; +} + +/** + * Use Typed Array if possible for performance optimization, otherwise fallback to a normal array. + * + * Usage + * const tyArr = tryEnsureCompatibleTypedArray({ctor: Float64ArrayCtor}, capacity); + */ +export type CompatibleTypedArray = { + // Write by this method. + // If null/undefined, create one. + // Never be null/undefined after `tryEnsureTypedArray` is called. + arr?: TypedArrayType | number[]; + // Write by this method. + // Whether is actually typed array. + // Never be null/undefined after `tryEnsureTypedArray` is called. + typed?: boolean; + + // Need to be provided by callers. + // Expected constructor. Do not change it. + ctor: TypedArrayCtor; +}; +export function tryEnsureTypedArray( + tyArr: CompatibleTypedArray, + // Can add more types if needed. + // NOTICE: Callers need to manage data length themselves. + // Do not consider `capacity` as the data length. + capacity: number +): CompatibleTypedArray { + if (__DEV__) { + assert( + capacity != null && isFinite(capacity) && capacity >= 0 + && tyArr.hasOwnProperty('ctor') + ); } - // Else is number - return new Float32ArrayCtor(arg); -} \ No newline at end of file + const existingArr = tyArr.arr; + const ctor = tyArr.ctor; + + if (capacity > MAX_SAFE_INTEGER) { + capacity = MAX_SAFE_INTEGER; + } + + if (!existingArr || (tyArr.typed && existingArr.length < capacity)) { + let nextArr: TypedArrayType | number[]; + if (ctor) { + try { + // A large contiguous memory allocation may cause OOM. + nextArr = new ctor(capacity); + tyArr.typed = true; + existingArr && nextArr.set(existingArr); + } + catch (e) { + if (__DEV__) { + error(e); + } + } + } + if (!nextArr) { + nextArr = []; + tyArr.typed = false; + if (existingArr) { + for (let i = 0, len = existingArr.length; i < len; i++) { + nextArr[i] = existingArr[i]; + } + } + } + tyArr.arr = nextArr; + } + + return tyArr; +} diff --git a/test/bar-overflow-plot2.html b/test/bar-overflow-plot2.html index c297ef4109..03647bf628 100644 --- a/test/bar-overflow-plot2.html +++ b/test/bar-overflow-plot2.html @@ -44,6 +44,7 @@ useDataZoom: true, xAxisShowMinMaxLabel: undefined, dataZoomLabelPrecision: undefined, + stack: false, }; function createData() { @@ -134,16 +135,25 @@ series: [{ name: 'bar_a', type: 'bar', + itemStyle: { + barBorderRadius: 3, + }, // barMaxWidth: 10, data: _data.dataSet.bar_a }, { name: 'bar_b', type: 'bar', + itemStyle: { + barBorderRadius: 3, + }, // barMaxWidth: 10, data: _data.dataSet.bar_b }, { name: 'bar_partially_negative', type: 'bar', + itemStyle: { + barBorderRadius: 3, + }, // barMaxWidth: 10, data: _data.dataSet.bar_partially_negative }] @@ -152,6 +162,11 @@ if (_ctx.yAxisOnZero !== 'unspecified') { option.yAxis.axisLine.onZero = _ctx.yAxisOnZero; } + if (_ctx.stack) { + option.series.forEach(individualSeries => { + individualSeries.stack = 'a'; + }); + } return option; } @@ -203,6 +218,14 @@ _ctx.dataZoomLabelPrecision = this.value; updateChart(); } + }, { + type: 'select', + text: 'stack', + values: [false, true], + onchange() { + _ctx.stack = this.value; + updateChart(); + } }] }); }) diff --git a/test/bar-overflow-time-plot.html b/test/bar-overflow-time-plot.html index d5ebc4853f..6ecafd5e64 100644 --- a/test/bar-overflow-time-plot.html +++ b/test/bar-overflow-time-plot.html @@ -41,6 +41,7 @@ var _ctx = { useDataZoom: true, + stack: false, xAxisInverse: undefined, datasource: 'simple', xAxisType: 'time', @@ -54,6 +55,7 @@ barCategoryGap: undefined, xAxisLabelShowMinMaxLabel: undefined, xAxisBoundaryGap: undefined, + xAxisContainShape: undefined, }; function makeLogDataIfNeeded(linearData) { @@ -201,6 +203,7 @@ }, inverse: _ctx.xAxisInverse, boundaryGap: _ctx.xAxisBoundaryGap, + containShape: _ctx.xAxisContainShape, splitArea: { show: true, }, @@ -282,6 +285,11 @@ series.clip = _ctx.seriesBarClip; }); } + if (_ctx.stack) { + option.series.forEach(individualSeries => { + individualSeries.stack = 'a'; + }); + } return option; } @@ -335,6 +343,14 @@ _ctx.useDataZoom = this.value; resetOption(); } + }, { + type: 'select', + text: 'stack:', + values: [false, true], + onchange() { + _ctx.stack = this.value; + resetOption(); + } }, { type: 'br', }, { @@ -379,14 +395,19 @@ values: [ _ctx.xAxisBoundaryGap, ['30%', '15%'], - [{containShape: false}, {containShape: false}], - [{containShape: false, value: '30%'}, {containShape: false, value: '15%'}], - [{containShape: true, value: '30%'}, {containShape: true, value: '15%'}], ], onchange() { _ctx.xAxisBoundaryGap = this.value; resetOption(); } + }, { + type: 'select', + text: 'xAxis.containShape:', + values: [_ctx.xAxisContainShape, false, true], + onchange() { + _ctx.xAxisContainShape = this.value; + resetOption(); + } }, { type: 'br', }, { diff --git a/test/bar-polar-multi-series-radial.html b/test/bar-polar-multi-series-radial.html index 9399273a25..ec2bbb00d3 100644 --- a/test/bar-polar-multi-series-radial.html +++ b/test/bar-polar-multi-series-radial.html @@ -21,29 +21,26 @@ + - + + + + -
- + + + + + + diff --git a/test/bar-polar-multi-series.html b/test/bar-polar-multi-series.html index 685f075651..6a8e607980 100644 --- a/test/bar-polar-multi-series.html +++ b/test/bar-polar-multi-series.html @@ -21,32 +21,31 @@ + - + + + + -
+ +
+
+ + + + + + + + + diff --git a/test/boxplot-multi.html b/test/boxplot-multi.html index db27f88a0b..31144d854f 100644 --- a/test/boxplot-multi.html +++ b/test/boxplot-multi.html @@ -24,18 +24,19 @@ + - + + -
-
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/test/build/mktest-tpl.html b/test/build/mktest-tpl.html index 629e3353f6..eea2982bcf 100644 --- a/test/build/mktest-tpl.html +++ b/test/build/mktest-tpl.html @@ -75,7 +75,7 @@ var chart = testHelper.create(echarts, '{{TPL_DOM_ID}}', { title: [ 'Test Case Description of {{TPL_DOM_ID}}', - '(Muliple lines and **emphasis** are supported in description)' + '(Multiple lines and **emphasis** are supported in description)' ], option: option, diff --git a/test/candlestick-case.html b/test/candlestick-case.html index 6a408a644a..47481605cd 100644 --- a/test/candlestick-case.html +++ b/test/candlestick-case.html @@ -37,10 +37,190 @@ + +
+
+ + + + @@ -56,98 +236,7 @@ var downColor = '#00da3c'; var downBorderColor = '#008F28'; - // 数据意义:开盘(open),收盘(close),最低(lowest),最高(highest) - var data0 = splitData([ - ['2013/1/24', 2320.26,2320.26,2287.3,2362.94], - ['2013/1/25', 2300,2291.3,2288.26,2308.38], - ['2013/1/28', 2295.35,2346.5,2295.35,2346.92], - ['2013/1/29', 2347.22,2358.98,2337.35,2363.8], - ['2013/1/30', 2360.75,2382.48,2347.89,2383.76], - ['2013/1/31', 2383.43,2385.42,2371.23,2391.82], - ['2013/2/1', 2377.41,2419.02,2369.57,2421.15], - ['2013/2/4', 2425.92,2428.15,2417.58,2440.38], - ['2013/2/5', 2411,2433.13,2403.3,2437.42], - ['2013/2/6', 2432.68,2434.48,2427.7,2441.73], - ['2013/2/7', 2430.69,2418.53,2394.22,2433.89], - ['2013/2/8', 2416.62,2432.4,2414.4,2443.03], - ['2013/2/18', 2441.91,2421.56,2415.43,2444.8], - ['2013/2/19', 2420.26,2382.91,2373.53,2427.07], - ['2013/2/20', 2383.49,2397.18,2370.61,2397.94], - ['2013/2/21', 2378.82,2325.95,2309.17,2378.82], - ['2013/2/22', 2322.94,2314.16,2308.76,2330.88], - ['2013/2/25', 2320.62,2325.82,2315.01,2338.78], - ['2013/2/26', 2313.74,2293.34,2289.89,2340.71], - ['2013/2/27', 2297.77,2313.22,2292.03,2324.63], - ['2013/2/28', 2322.32,2365.59,2308.92,2366.16], - ['2013/3/1', 2364.54,2359.51,2330.86,2369.65], - ['2013/3/4', 2332.08,2273.4,2259.25,2333.54], - ['2013/3/5', 2274.81,2326.31,2270.1,2328.14], - ['2013/3/6', 2333.61,2347.18,2321.6,2351.44], - ['2013/3/7', 2340.44,2324.29,2304.27,2352.02], - ['2013/3/8', 2326.42,2318.61,2314.59,2333.67], - ['2013/3/11', 2314.68,2310.59,2296.58,2320.96], - ['2013/3/12', 2309.16,2286.6,2264.83,2333.29], - ['2013/3/13', 2282.17,2263.97,2253.25,2286.33], - ['2013/3/14', 2255.77,2270.28,2253.31,2276.22], - ['2013/3/15', 2269.31,2278.4,2250,2312.08], - ['2013/3/18', 2267.29,2240.02,2239.21,2276.05], - ['2013/3/19', 2244.26,2257.43,2232.02,2261.31], - ['2013/3/20', 2257.74,2317.37,2257.42,2317.86], - ['2013/3/21', 2318.21,2324.24,2311.6,2330.81], - ['2013/3/22', 2321.4,2328.28,2314.97,2332], - ['2013/3/25', 2334.74,2326.72,2319.91,2344.89], - ['2013/3/26', 2318.58,2297.67,2281.12,2319.99], - ['2013/3/27', 2299.38,2301.26,2289,2323.48], - ['2013/3/28', 2273.55,2236.3,2232.91,2273.55], - ['2013/3/29', 2238.49,2236.62,2228.81,2246.87], - ['2013/4/1', 2229.46,2234.4,2227.31,2243.95], - ['2013/4/2', 2234.9,2227.74,2220.44,2253.42], - ['2013/4/3', 2232.69,2225.29,2217.25,2241.34], - ['2013/4/8', 2196.24,2211.59,2180.67,2212.59], - ['2013/4/9', 2215.47,2225.77,2215.47,2234.73], - ['2013/4/10', 2224.93,2226.13,2212.56,2233.04], - ['2013/4/11', 2236.98,2219.55,2217.26,2242.48], - ['2013/4/12', 2218.09,2206.78,2204.44,2226.26], - ['2013/4/15', 2199.91,2181.94,2177.39,2204.99], - ['2013/4/16', 2169.63,2194.85,2165.78,2196.43], - ['2013/4/17', 2195.03,2193.8,2178.47,2197.51], - ['2013/4/18', 2181.82,2197.6,2175.44,2206.03], - ['2013/4/19', 2201.12,2244.64,2200.58,2250.11], - ['2013/4/22', 2236.4,2242.17,2232.26,2245.12], - ['2013/4/23', 2242.62,2184.54,2182.81,2242.62], - ['2013/4/24', 2187.35,2218.32,2184.11,2226.12], - ['2013/4/25', 2213.19,2199.31,2191.85,2224.63], - ['2013/4/26', 2203.89,2177.91,2173.86,2210.58], - ['2013/5/2', 2170.78,2174.12,2161.14,2179.65], - ['2013/5/3', 2179.05,2205.5,2179.05,2222.81], - ['2013/5/6', 2212.5,2231.17,2212.5,2236.07], - ['2013/5/7', 2227.86,2235.57,2219.44,2240.26], - ['2013/5/8', 2242.39,2246.3,2235.42,2255.21], - ['2013/5/9', 2246.96,2232.97,2221.38,2247.86], - ['2013/5/10', 2228.82,2246.83,2225.81,2247.67], - ['2013/5/13', 2247.68,2241.92,2231.36,2250.85], - ['2013/5/14', 2238.9,2217.01,2205.87,2239.93], - ['2013/5/15', 2217.09,2224.8,2213.58,2225.19], - ['2013/5/16', 2221.34,2251.81,2210.77,2252.87], - ['2013/5/17', 2249.81,2282.87,2248.41,2288.09], - ['2013/5/20', 2286.33,2299.99,2281.9,2309.39], - ['2013/5/21', 2297.11,2305.11,2290.12,2305.3], - ['2013/5/22', 2303.75,2302.4,2292.43,2314.18], - ['2013/5/23', 2293.81,2275.67,2274.1,2304.95], - ['2013/5/24', 2281.45,2288.53,2270.25,2292.59], - ['2013/5/27', 2286.66,2293.08,2283.94,2301.7], - ['2013/5/28', 2293.4,2321.32,2281.47,2322.1], - ['2013/5/29', 2323.54,2324.02,2321.17,2334.33], - ['2013/5/30', 2316.25,2317.75,2310.49,2325.72], - ['2013/5/31', 2320.74,2300.59,2299.37,2325.53], - ['2013/6/3', 2300.21,2299.25,2294.11,2313.43], - ['2013/6/4', 2297.1,2272.42,2264.76,2297.1], - ['2013/6/5', 2270.71,2270.93,2260.87,2276.86], - ['2013/6/6', 2264.43,2242.11,2240.07,2266.69], - ['2013/6/7', 2242.26,2210.9,2205.07,2250.63], - ['2013/6/13', 2190.1,2148.35,2126.22,2190.1] - ]); - + var data0 = splitData(createRawData()); function splitData(rawData) { var categoryData = []; @@ -192,8 +281,6 @@ return result; } - - option = { title: { text: '上证指数', @@ -363,6 +450,8 @@ }); + + + + + + + + + + + + + + + + + + + + + diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index 140321d16d..6328f01583 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -33,7 +33,7 @@ "bar-overflow-time-plot": 3, "bar-polar-animation": 2, "bar-polar-clockwise": 1, - "bar-polar-multi-series": 1, + "bar-polar-multi-series": 2, "bar-polar-multi-series-radial": 1, "bar-polar-null-data-radial": 1, "bar-polar-stack": 1, @@ -54,7 +54,7 @@ "calendar-heatmap": 1, "calendar-month": 1, "candlestick": 2, - "candlestick-case": 2, + "candlestick-case": 3, "candlestick-empty": 1, "candlestick-large": 4, "candlestick-large2": 1, diff --git a/test/runTest/actions/bar-polar-multi-series.json b/test/runTest/actions/bar-polar-multi-series.json index 3a84cf8db1..aca063daf3 100644 --- a/test/runTest/actions/bar-polar-multi-series.json +++ b/test/runTest/actions/bar-polar-multi-series.json @@ -1 +1 @@ -[{"name":"Action 1","ops":[{"type":"mousedown","time":393,"x":345,"y":14},{"type":"mouseup","time":486,"x":345,"y":14},{"time":487,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":629,"x":346,"y":14},{"type":"mousemove","time":829,"x":377,"y":12},{"type":"mousemove","time":1037,"x":381,"y":12},{"type":"mousedown","time":1278,"x":381,"y":12},{"type":"mouseup","time":1387,"x":381,"y":12},{"time":1388,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":1454,"x":382,"y":12},{"type":"mousemove","time":1654,"x":425,"y":12},{"type":"mousemove","time":1864,"x":431,"y":10},{"type":"mousemove","time":2064,"x":432,"y":10},{"type":"mousedown","time":2115,"x":432,"y":10},{"type":"mouseup","time":2196,"x":432,"y":10},{"time":2197,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":2945,"x":432,"y":10},{"type":"mouseup","time":3080,"x":432,"y":10},{"time":3081,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3217,"x":431,"y":10},{"type":"mousemove","time":3417,"x":398,"y":13},{"type":"mousedown","time":3764,"x":398,"y":13},{"type":"mouseup","time":3865,"x":398,"y":13},{"time":3866,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3967,"x":397,"y":13},{"type":"mousemove","time":4168,"x":357,"y":17},{"type":"mousemove","time":4368,"x":343,"y":16},{"type":"mousedown","time":4507,"x":343,"y":16},{"type":"mouseup","time":4616,"x":343,"y":16},{"time":4617,"delay":400,"type":"screenshot-auto"}],"scrollY":0,"scrollX":0,"timestamp":1568018156413}] \ No newline at end of file +[{"name":"Action 1","ops":[{"type":"mousemove","time":21,"x":797,"y":233},{"type":"mousemove","time":226,"x":629,"y":329},{"type":"mousemove","time":445,"x":618,"y":334},{"type":"mousemove","time":653,"x":634,"y":328},{"type":"mousemove","time":863,"x":552,"y":358},{"type":"mousemove","time":1068,"x":433,"y":410},{"type":"mousemove","time":1275,"x":414,"y":424},{"type":"mousemove","time":1484,"x":413,"y":309},{"type":"mousemove","time":1684,"x":405,"y":280},{"type":"mousemove","time":1884,"x":433,"y":229},{"type":"mousemove","time":2084,"x":429,"y":263},{"type":"mousemove","time":2284,"x":402,"y":423},{"type":"mousemove","time":2484,"x":390,"y":510},{"type":"mousemove","time":2684,"x":383,"y":514},{"type":"mousemove","time":2891,"x":349,"y":530},{"type":"mousedown","time":3041,"x":348,"y":530},{"type":"mousemove","time":3140,"x":348,"y":530},{"type":"mouseup","time":3174,"x":348,"y":530},{"time":3175,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3416,"x":349,"y":530},{"type":"mousemove","time":3616,"x":388,"y":533},{"type":"mousedown","time":3794,"x":392,"y":533},{"type":"mousemove","time":3823,"x":392,"y":533},{"type":"mouseup","time":3926,"x":392,"y":533},{"time":3927,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4049,"x":396,"y":533},{"type":"mousemove","time":4256,"x":444,"y":533},{"type":"mousedown","time":4277,"x":444,"y":533},{"type":"mouseup","time":4410,"x":444,"y":533},{"time":4411,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4464,"x":444,"y":533},{"type":"mousemove","time":4674,"x":410,"y":533},{"type":"mousemove","time":4881,"x":399,"y":533},{"type":"mousedown","time":4959,"x":399,"y":533},{"type":"mouseup","time":5059,"x":399,"y":533},{"time":5060,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5231,"x":399,"y":533},{"type":"mousemove","time":5440,"x":438,"y":530},{"type":"mousedown","time":5476,"x":438,"y":530},{"type":"mouseup","time":5609,"x":438,"y":530},{"time":5610,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":5680,"x":437,"y":530},{"type":"mousemove","time":5880,"x":369,"y":530},{"type":"mousemove","time":6080,"x":348,"y":530},{"type":"mousedown","time":6099,"x":348,"y":530},{"type":"mouseup","time":6258,"x":348,"y":530},{"time":6259,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6679,"x":348,"y":530},{"type":"mousemove","time":6879,"x":375,"y":517},{"type":"mousemove","time":7088,"x":464,"y":358},{"type":"mousemove","time":7295,"x":619,"y":362},{"type":"mousemove","time":7505,"x":655,"y":388}],"scrollY":0,"scrollX":0,"timestamp":1772564634729},{"name":"Action 2","ops":[{"type":"mousemove","time":507,"x":643,"y":199},{"type":"mousemove","time":707,"x":534,"y":267},{"type":"mousemove","time":907,"x":489,"y":318},{"type":"mousemove","time":1107,"x":423,"y":385},{"type":"mousemove","time":1319,"x":363,"y":425},{"type":"mousemove","time":1523,"x":428,"y":411},{"type":"mousemove","time":1723,"x":497,"y":405},{"type":"mousemove","time":1937,"x":549,"y":384},{"type":"mousemove","time":2150,"x":558,"y":377},{"type":"mousemove","time":2206,"x":558,"y":377},{"type":"mousemove","time":2406,"x":493,"y":315},{"type":"mousemove","time":2618,"x":493,"y":315},{"type":"mousemove","time":2773,"x":475,"y":315},{"type":"mousemove","time":2973,"x":73,"y":330},{"type":"mousemove","time":3173,"x":54,"y":339},{"type":"mousedown","time":3268,"x":51,"y":339},{"type":"mouseup","time":3363,"x":51,"y":339},{"time":3364,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":3399,"x":51,"y":339},{"type":"mousemove","time":3506,"x":51,"y":339},{"type":"mousemove","time":3706,"x":50,"y":358},{"type":"mousedown","time":3916,"x":49,"y":363},{"type":"mousemove","time":3935,"x":49,"y":363},{"type":"mouseup","time":4030,"x":49,"y":363},{"time":4031,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4205,"x":49,"y":362},{"type":"mousemove","time":4405,"x":50,"y":350},{"type":"mousemove","time":4605,"x":52,"y":344},{"type":"mousedown","time":4645,"x":52,"y":344},{"type":"mouseup","time":4795,"x":52,"y":344},{"time":4796,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":4819,"x":52,"y":344},{"type":"mousemove","time":4905,"x":52,"y":345},{"type":"mousemove","time":5117,"x":52,"y":357},{"type":"mousemove","time":5321,"x":52,"y":360},{"type":"mousemove","time":5533,"x":52,"y":361},{"type":"mousedown","time":5784,"x":52,"y":361},{"type":"mouseup","time":5881,"x":52,"y":361},{"time":5882,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6104,"x":53,"y":361},{"type":"mousemove","time":6325,"x":542,"y":258},{"type":"mousemove","time":6534,"x":597,"y":245},{"type":"mousemove","time":6735,"x":701,"y":231},{"type":"mousemove","time":6937,"x":746,"y":202},{"type":"mousemove","time":7138,"x":764,"y":196},{"type":"mousemove","time":7350,"x":766,"y":200},{"type":"mousemove","time":7556,"x":765,"y":203},{"type":"mousedown","time":7850,"x":765,"y":203},{"type":"mousemove","time":7859,"x":765,"y":204},{"type":"mousemove","time":8082,"x":765,"y":218},{"type":"mousemove","time":8331,"x":765,"y":223},{"type":"mousemove","time":8555,"x":766,"y":254},{"type":"mousemove","time":8775,"x":770,"y":283},{"type":"mousemove","time":8937,"x":770,"y":285},{"type":"mousemove","time":9148,"x":772,"y":345},{"type":"mousemove","time":9365,"x":775,"y":410},{"type":"mousemove","time":9636,"x":776,"y":410},{"type":"mousemove","time":9836,"x":775,"y":443},{"type":"mousemove","time":10037,"x":776,"y":439},{"type":"mousemove","time":10246,"x":777,"y":437},{"type":"mousemove","time":10369,"x":777,"y":437},{"type":"mousemove","time":10580,"x":777,"y":436},{"type":"mouseup","time":10781,"x":777,"y":436},{"time":10782,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":10803,"x":779,"y":439},{"type":"mousemove","time":11014,"x":765,"y":465},{"type":"mousemove","time":11229,"x":745,"y":471},{"type":"mousemove","time":11319,"x":745,"y":471},{"type":"mousemove","time":11529,"x":754,"y":468},{"type":"mousedown","time":11964,"x":754,"y":468},{"type":"mousemove","time":11973,"x":754,"y":468},{"type":"mousemove","time":12180,"x":754,"y":462},{"type":"mousemove","time":12395,"x":755,"y":461},{"type":"mousemove","time":12502,"x":755,"y":461},{"type":"mousemove","time":12713,"x":756,"y":455},{"type":"mousemove","time":12918,"x":756,"y":450},{"type":"mousemove","time":13127,"x":756,"y":449},{"type":"mousemove","time":13169,"x":756,"y":449},{"type":"mousemove","time":13379,"x":751,"y":370},{"type":"mousemove","time":13552,"x":751,"y":370},{"type":"mousemove","time":13762,"x":753,"y":309},{"type":"mousemove","time":13968,"x":754,"y":276},{"type":"mousemove","time":14168,"x":752,"y":253},{"type":"mousemove","time":14368,"x":751,"y":226},{"type":"mousemove","time":14577,"x":751,"y":225},{"type":"mousemove","time":14785,"x":746,"y":200},{"type":"mousemove","time":14993,"x":746,"y":200},{"type":"mousemove","time":15151,"x":746,"y":200},{"type":"mousemove","time":15363,"x":746,"y":201},{"type":"mousemove","time":15668,"x":746,"y":202},{"type":"mousemove","time":15868,"x":748,"y":217},{"type":"mousemove","time":16079,"x":750,"y":228},{"type":"mousemove","time":16284,"x":752,"y":247},{"type":"mousemove","time":16495,"x":752,"y":248},{"type":"mouseup","time":16563,"x":752,"y":248},{"time":16564,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":16573,"x":733,"y":253},{"type":"mousemove","time":16781,"x":596,"y":300},{"type":"mousemove","time":16985,"x":559,"y":322},{"type":"mousemove","time":17195,"x":544,"y":331},{"type":"mousemove","time":17368,"x":544,"y":331},{"type":"mousemove","time":17568,"x":452,"y":368},{"type":"mousemove","time":17768,"x":423,"y":389},{"type":"mousemove","time":17978,"x":400,"y":407},{"type":"mousemove","time":18184,"x":379,"y":417},{"type":"mousemove","time":18384,"x":384,"y":323},{"type":"mousemove","time":18587,"x":386,"y":333},{"type":"mousemove","time":18796,"x":730,"y":311},{"type":"mousemove","time":19001,"x":758,"y":303},{"type":"mousemove","time":19212,"x":769,"y":282},{"type":"mousemove","time":19417,"x":775,"y":311},{"type":"mousemove","time":19617,"x":778,"y":320},{"type":"mousemove","time":19827,"x":773,"y":318},{"type":"mousedown","time":20063,"x":773,"y":318},{"type":"mousemove","time":20071,"x":773,"y":318},{"type":"mousemove","time":20280,"x":769,"y":519},{"type":"mousemove","time":20484,"x":769,"y":520},{"type":"mousemove","time":20695,"x":768,"y":518},{"type":"mouseup","time":20781,"x":768,"y":518},{"time":20782,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":20793,"x":713,"y":469},{"type":"mousemove","time":20995,"x":445,"y":312},{"type":"mousemove","time":21200,"x":478,"y":276},{"type":"mousemove","time":21414,"x":527,"y":227},{"type":"mousemove","time":21617,"x":547,"y":219},{"type":"mousemove","time":21827,"x":550,"y":218},{"type":"mousemove","time":22067,"x":553,"y":218},{"type":"mousemove","time":22278,"x":556,"y":218},{"type":"mousemove","time":22511,"x":535,"y":231},{"type":"mousemove","time":22767,"x":537,"y":231},{"type":"mousemove","time":22967,"x":743,"y":296},{"type":"mousemove","time":23167,"x":779,"y":254},{"type":"mousemove","time":23367,"x":777,"y":252},{"type":"mousemove","time":23578,"x":775,"y":252},{"type":"mousedown","time":23712,"x":775,"y":252},{"type":"mousemove","time":23721,"x":775,"y":252},{"type":"mousemove","time":23930,"x":767,"y":149},{"type":"mouseup","time":24163,"x":767,"y":149},{"time":24164,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":24172,"x":728,"y":158},{"type":"mousemove","time":24382,"x":552,"y":200},{"type":"mousemove","time":24584,"x":541,"y":210},{"type":"mousemove","time":24785,"x":539,"y":213},{"type":"mousemove","time":24996,"x":538,"y":215},{"type":"mousemove","time":25210,"x":494,"y":259},{"type":"mousemove","time":25420,"x":425,"y":325},{"type":"mousemove","time":25630,"x":410,"y":335},{"type":"mousemove","time":25839,"x":390,"y":347},{"type":"mousemove","time":26046,"x":390,"y":348},{"type":"mousemove","time":26250,"x":456,"y":339},{"type":"mousemove","time":26457,"x":544,"y":304},{"type":"mousemove","time":26667,"x":642,"y":263},{"type":"mousedown","time":26759,"x":646,"y":262},{"type":"mousemove","time":26876,"x":646,"y":262},{"type":"mouseup","time":26885,"x":646,"y":262},{"time":26886,"delay":400,"type":"screenshot-auto"}],"scrollY":535.5,"scrollX":0,"timestamp":1772564646913}] \ No newline at end of file diff --git a/test/runTest/actions/candlestick-case.json b/test/runTest/actions/candlestick-case.json index 0b07b405ea..aff7ccf911 100644 --- a/test/runTest/actions/candlestick-case.json +++ b/test/runTest/actions/candlestick-case.json @@ -1 +1 @@ -[{"name":"Action 1","ops":[{"type":"mousedown","time":300,"x":50,"y":77},{"type":"mouseup","time":423,"x":50,"y":77},{"time":424,"delay":400,"type":"screenshot-auto"}],"scrollY":0,"scrollX":0,"timestamp":1626405373145},{"name":"Action 2","ops":[{"type":"mousemove","time":303,"x":145,"y":215},{"type":"mousemove","time":505,"x":169,"y":336},{"type":"mousemove","time":722,"x":170,"y":369},{"type":"screenshot","time":1492},{"type":"mousemove","time":1769,"x":199,"y":364},{"type":"mousemove","time":1969,"x":287,"y":329},{"type":"mousemove","time":2172,"x":315,"y":329},{"type":"screenshot","time":2995},{"type":"mousemove","time":3319,"x":318,"y":329},{"type":"mousemove","time":3523,"x":446,"y":340},{"type":"mousemove","time":3742,"x":462,"y":343},{"type":"screenshot","time":4427},{"type":"mousemove","time":4885,"x":462,"y":344},{"type":"mousemove","time":5085,"x":639,"y":376},{"type":"mousemove","time":5290,"x":638,"y":381},{"type":"screenshot","time":6027}],"scrollY":357,"scrollX":0,"timestamp":1719893340178}] \ No newline at end of file +[{"name":"Action 1","ops":[{"type":"mousedown","time":300,"x":50,"y":77},{"type":"mouseup","time":423,"x":50,"y":77},{"time":424,"delay":400,"type":"screenshot-auto"}],"scrollY":0,"scrollX":0,"timestamp":1626405373145},{"name":"Action 2","ops":[{"type":"mousemove","time":303,"x":145,"y":215},{"type":"mousemove","time":505,"x":169,"y":336},{"type":"mousemove","time":722,"x":170,"y":369},{"type":"screenshot","time":1492},{"type":"mousemove","time":1769,"x":199,"y":364},{"type":"mousemove","time":1969,"x":287,"y":329},{"type":"mousemove","time":2172,"x":315,"y":329},{"type":"screenshot","time":2995},{"type":"mousemove","time":3319,"x":318,"y":329},{"type":"mousemove","time":3523,"x":446,"y":340},{"type":"mousemove","time":3742,"x":462,"y":343},{"type":"screenshot","time":4427},{"type":"mousemove","time":4885,"x":462,"y":344},{"type":"mousemove","time":5085,"x":639,"y":376},{"type":"mousemove","time":5290,"x":638,"y":381},{"type":"screenshot","time":6027}],"scrollY":357,"scrollX":0,"timestamp":1719893340178},{"name":"Action 3","ops":[{"type":"mousemove","time":249,"x":620,"y":240},{"type":"mousemove","time":455,"x":309,"y":238},{"type":"mousemove","time":673,"x":204,"y":226},{"type":"mousemove","time":781,"x":204,"y":223},{"type":"mousemove","time":981,"x":193,"y":165},{"type":"mousemove","time":1182,"x":254,"y":170},{"type":"mousemove","time":1388,"x":338,"y":167},{"type":"mousemove","time":1732,"x":338,"y":168},{"type":"mousemove","time":1940,"x":240,"y":327},{"type":"mousewheel","time":2148,"x":240,"y":327,"deltaY":6},{"type":"mousewheel","time":2180,"x":240,"y":327,"deltaY":19},{"type":"mousewheel","time":2199,"x":240,"y":327,"deltaY":22},{"type":"mousewheel","time":2217,"x":240,"y":327,"deltaY":20},{"type":"mousewheel","time":2234,"x":240,"y":327,"deltaY":15},{"type":"mousewheel","time":2254,"x":240,"y":327,"deltaY":10},{"type":"mousewheel","time":2282,"x":240,"y":327,"deltaY":11},{"type":"mousewheel","time":2300,"x":240,"y":327,"deltaY":3},{"type":"mousewheel","time":2317,"x":240,"y":327,"deltaY":2},{"type":"mousewheel","time":2335,"x":240,"y":327,"deltaY":1},{"type":"mousewheel","time":2353,"x":240,"y":327,"deltaY":4},{"type":"mousewheel","time":2372,"x":240,"y":327,"deltaY":4},{"type":"mousewheel","time":2402,"x":240,"y":327,"deltaY":8},{"type":"mousewheel","time":2421,"x":240,"y":327,"deltaY":4},{"type":"mousewheel","time":2440,"x":240,"y":327,"deltaY":3},{"type":"mousewheel","time":2460,"x":240,"y":327,"deltaY":7},{"type":"mousewheel","time":2478,"x":240,"y":327,"deltaY":3},{"type":"mousewheel","time":2506,"x":240,"y":327,"deltaY":3},{"type":"mousewheel","time":2527,"x":240,"y":327,"deltaY":5},{"type":"mousewheel","time":2547,"x":240,"y":327,"deltaY":2},{"type":"mousewheel","time":2567,"x":240,"y":327,"deltaY":2},{"type":"mousewheel","time":2587,"x":240,"y":327,"deltaY":2},{"type":"mousewheel","time":2615,"x":240,"y":327,"deltaY":2},{"type":"mousewheel","time":2635,"x":240,"y":327,"deltaY":1},{"type":"mousewheel","time":2655,"x":240,"y":327,"deltaY":1},{"type":"mousewheel","time":2674,"x":240,"y":327,"deltaY":2},{"type":"mousewheel","time":2703,"x":240,"y":327,"deltaY":1},{"type":"mousewheel","time":2729,"x":240,"y":327,"deltaY":1},{"type":"mousewheel","time":2750,"x":240,"y":327,"deltaY":1},{"type":"mousemove","time":3200,"x":240,"y":327},{"type":"mousemove","time":3409,"x":530,"y":424},{"type":"mousemove","time":3624,"x":367,"y":386},{"type":"mousewheel","time":3815,"x":367,"y":386,"deltaY":-2},{"type":"mousewheel","time":3838,"x":367,"y":386,"deltaY":-13},{"type":"mousewheel","time":3856,"x":367,"y":386,"deltaY":-19},{"type":"mousewheel","time":3873,"x":367,"y":386,"deltaY":-25},{"type":"mousewheel","time":3891,"x":367,"y":386,"deltaY":-40},{"type":"mousewheel","time":3910,"x":367,"y":386,"deltaY":-15},{"type":"mousewheel","time":3927,"x":367,"y":386,"deltaY":-26},{"type":"mousewheel","time":3952,"x":367,"y":386,"deltaY":-60},{"type":"mousewheel","time":3969,"x":367,"y":386,"deltaY":-31},{"type":"mousewheel","time":3987,"x":367,"y":386,"deltaY":-30},{"type":"mousewheel","time":4006,"x":367,"y":386,"deltaY":-29},{"type":"mousewheel","time":4023,"x":367,"y":386,"deltaY":-34},{"type":"mousewheel","time":4041,"x":367,"y":386,"deltaY":-32},{"type":"mousewheel","time":4066,"x":367,"y":386,"deltaY":-57},{"type":"mousewheel","time":4084,"x":367,"y":386,"deltaY":-24},{"type":"mousewheel","time":4103,"x":367,"y":386,"deltaY":-22},{"type":"mousewheel","time":4122,"x":367,"y":386,"deltaY":-20},{"type":"mousewheel","time":4140,"x":367,"y":386,"deltaY":-19},{"type":"mousewheel","time":4158,"x":367,"y":386,"deltaY":-17},{"type":"mousewheel","time":4187,"x":367,"y":386,"deltaY":-30},{"type":"mousewheel","time":4206,"x":367,"y":386,"deltaY":-13},{"type":"mousewheel","time":4224,"x":367,"y":386,"deltaY":-12},{"type":"mousewheel","time":4242,"x":367,"y":386,"deltaY":-11},{"type":"mousewheel","time":4260,"x":367,"y":386,"deltaY":-10},{"type":"mousewheel","time":4289,"x":367,"y":386,"deltaY":-17},{"type":"mousewheel","time":4308,"x":367,"y":386,"deltaY":-8},{"type":"mousewheel","time":4327,"x":367,"y":386,"deltaY":-7},{"type":"mousewheel","time":4346,"x":367,"y":386,"deltaY":-13},{"type":"mousewheel","time":4365,"x":367,"y":386,"deltaY":-6},{"type":"mousewheel","time":4393,"x":367,"y":386,"deltaY":-5},{"type":"mousewheel","time":4412,"x":367,"y":386,"deltaY":-10},{"type":"mousewheel","time":4434,"x":367,"y":386,"deltaY":-4},{"type":"mousewheel","time":4453,"x":367,"y":386,"deltaY":-4},{"type":"mousewheel","time":4472,"x":367,"y":386,"deltaY":-4},{"type":"mousewheel","time":4491,"x":367,"y":386,"deltaY":-3},{"type":"mousewheel","time":4519,"x":367,"y":386,"deltaY":-3},{"type":"mousewheel","time":4539,"x":367,"y":386,"deltaY":-5},{"type":"mousewheel","time":4559,"x":367,"y":386,"deltaY":-2},{"type":"mousewheel","time":4578,"x":367,"y":386,"deltaY":-2},{"type":"mousewheel","time":4597,"x":367,"y":386,"deltaY":-4},{"type":"mousewheel","time":4617,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4637,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4657,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4674,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4693,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4709,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4726,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4768,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4932,"x":367,"y":386,"deltaY":-1},{"type":"mousewheel","time":4950,"x":367,"y":386,"deltaY":-6},{"type":"mousewheel","time":4969,"x":367,"y":386,"deltaY":-13},{"type":"mousewheel","time":4988,"x":367,"y":386,"deltaY":-20},{"type":"mousewheel","time":5007,"x":367,"y":386,"deltaY":-20},{"type":"mousewheel","time":5028,"x":367,"y":386,"deltaY":-31},{"type":"mousewheel","time":5046,"x":367,"y":386,"deltaY":-21},{"type":"mousewheel","time":5069,"x":367,"y":386,"deltaY":-23},{"type":"mousewheel","time":5086,"x":367,"y":386,"deltaY":-24},{"type":"mousewheel","time":5104,"x":367,"y":386,"deltaY":-24},{"type":"mousewheel","time":5123,"x":367,"y":386,"deltaY":-23},{"type":"mousewheel","time":5143,"x":367,"y":386,"deltaY":-48},{"type":"mousewheel","time":5162,"x":367,"y":386,"deltaY":-24},{"type":"mousewheel","time":5186,"x":367,"y":386,"deltaY":-22},{"type":"mousewheel","time":5203,"x":367,"y":386,"deltaY":-20},{"type":"mousemove","time":5222,"x":367,"y":382},{"type":"mousemove","time":5439,"x":369,"y":276},{"type":"mousemove","time":5646,"x":362,"y":249},{"type":"mousemove","time":5860,"x":361,"y":234},{"type":"mousedown","time":5869,"x":361,"y":234},{"type":"mouseup","time":5992,"x":361,"y":234},{"time":5993,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":6266,"x":361,"y":232},{"type":"mousedown","time":6396,"x":357,"y":220},{"type":"mousemove","time":6477,"x":357,"y":220},{"type":"mouseup","time":6513,"x":357,"y":220},{"time":6514,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":6895,"x":357,"y":220},{"type":"mouseup","time":6993,"x":357,"y":220},{"time":6994,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":7299,"x":357,"y":217},{"type":"mousemove","time":7500,"x":351,"y":170},{"type":"mousemove","time":7708,"x":348,"y":164},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"1","time":8650,"target":"select"},{"time":8651,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":8683,"x":405,"y":186},{"type":"mousemove","time":8888,"x":482,"y":182},{"type":"mousemove","time":9115,"x":483,"y":183},{"type":"mousemove","time":9318,"x":480,"y":184},{"type":"mousemove","time":9530,"x":478,"y":186},{"type":"mousemove","time":9742,"x":477,"y":186},{"type":"mousemove","time":9982,"x":477,"y":186},{"type":"mousemove","time":10182,"x":469,"y":192},{"type":"mousemove","time":10394,"x":425,"y":229},{"type":"mousemove","time":10599,"x":512,"y":175},{"type":"mousemove","time":10807,"x":521,"y":165},{"type":"mousemove","time":11023,"x":521,"y":165},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"1","time":11685,"target":"select"},{"time":11686,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":11712,"x":489,"y":198},{"type":"mousemove","time":11912,"x":478,"y":217},{"type":"mousemove","time":12122,"x":497,"y":226},{"type":"mousemove","time":12338,"x":497,"y":227},{"type":"mousedown","time":12486,"x":497,"y":227},{"type":"mouseup","time":12585,"x":497,"y":227},{"time":12586,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":12607,"x":496,"y":227},{"type":"mousemove","time":12807,"x":429,"y":227},{"type":"mousedown","time":13002,"x":414,"y":227},{"type":"mousemove","time":13011,"x":414,"y":227},{"type":"mouseup","time":13085,"x":414,"y":227},{"time":13086,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13258,"x":413,"y":227},{"type":"mousemove","time":13458,"x":268,"y":223},{"type":"mousedown","time":13537,"x":267,"y":222},{"type":"mouseup","time":13668,"x":267,"y":222},{"time":13669,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":13692,"x":267,"y":222},{"type":"mousemove","time":13724,"x":271,"y":222},{"type":"mousemove","time":13932,"x":372,"y":220},{"type":"mousedown","time":14081,"x":378,"y":220},{"type":"mousemove","time":14148,"x":378,"y":220},{"type":"mouseup","time":14248,"x":378,"y":220},{"time":14249,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14292,"x":381,"y":220},{"type":"mousemove","time":14502,"x":465,"y":220},{"type":"mousedown","time":14585,"x":468,"y":220},{"type":"mouseup","time":14716,"x":468,"y":220},{"time":14717,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":14747,"x":468,"y":220},{"type":"mousemove","time":14757,"x":467,"y":220},{"type":"mousemove","time":14967,"x":332,"y":223},{"type":"mousedown","time":15165,"x":261,"y":220},{"type":"mousemove","time":15186,"x":261,"y":220},{"type":"mouseup","time":15315,"x":261,"y":220},{"time":15316,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":15554,"x":261,"y":221},{"type":"mousemove","time":15755,"x":303,"y":384},{"type":"mousemove","time":15963,"x":304,"y":385},{"type":"mousewheel","time":16234,"x":304,"y":385,"deltaY":1},{"type":"mousewheel","time":16265,"x":304,"y":385,"deltaY":28},{"type":"mousewheel","time":16286,"x":304,"y":385,"deltaY":10},{"type":"mousewheel","time":16307,"x":304,"y":385,"deltaY":9},{"type":"mousewheel","time":16329,"x":304,"y":385,"deltaY":9},{"type":"mousewheel","time":16351,"x":304,"y":385,"deltaY":10},{"type":"mousewheel","time":16382,"x":304,"y":385,"deltaY":3},{"type":"mousewheel","time":16404,"x":304,"y":385,"deltaY":1},{"type":"mousewheel","time":16425,"x":304,"y":385,"deltaY":1},{"type":"mousewheel","time":16684,"x":304,"y":385,"deltaY":1},{"type":"mousewheel","time":16712,"x":304,"y":385,"deltaY":7},{"type":"mousewheel","time":16734,"x":304,"y":385,"deltaY":5},{"type":"mousewheel","time":16756,"x":304,"y":385,"deltaY":4},{"type":"mousewheel","time":16778,"x":304,"y":385,"deltaY":4},{"type":"mousewheel","time":16803,"x":304,"y":385,"deltaY":3},{"type":"mousewheel","time":16832,"x":304,"y":385,"deltaY":1},{"type":"mousewheel","time":16854,"x":304,"y":385,"deltaY":-1},{"type":"mousewheel","time":16876,"x":304,"y":385,"deltaY":-1},{"type":"mousewheel","time":16901,"x":304,"y":385,"deltaY":-1},{"type":"mousewheel","time":16922,"x":304,"y":385,"deltaY":-1},{"type":"mousewheel","time":16952,"x":304,"y":385,"deltaY":-3},{"type":"mousewheel","time":16974,"x":304,"y":385,"deltaY":-6},{"type":"mousewheel","time":16996,"x":304,"y":385,"deltaY":-26},{"type":"mousewheel","time":17020,"x":304,"y":385,"deltaY":-14},{"type":"mousewheel","time":17042,"x":304,"y":385,"deltaY":-9},{"type":"mousewheel","time":17079,"x":304,"y":385,"deltaY":-10},{"type":"mousewheel","time":17106,"x":304,"y":385,"deltaY":-3},{"type":"mousewheel","time":17165,"x":304,"y":385,"deltaY":0},{"type":"mousewheel","time":17188,"x":304,"y":385,"deltaY":1},{"type":"mousewheel","time":17211,"x":304,"y":385,"deltaY":2},{"type":"mousewheel","time":17235,"x":304,"y":385,"deltaY":3},{"type":"mousemove","time":17518,"x":304,"y":385},{"type":"mousemove","time":17725,"x":317,"y":494},{"type":"mousemove","time":17998,"x":316,"y":496},{"type":"mousemove","time":18201,"x":276,"y":557},{"type":"mousemove","time":18410,"x":274,"y":559},{"type":"mousemove","time":18691,"x":274,"y":558},{"type":"mousedown","time":18725,"x":274,"y":558},{"type":"mousemove","time":18736,"x":271,"y":557},{"type":"mousemove","time":18946,"x":133,"y":546},{"type":"mousemove","time":19147,"x":124,"y":547},{"type":"mousemove","time":19529,"x":124,"y":547},{"type":"mousemove","time":19732,"x":493,"y":496},{"type":"mousemove","time":19942,"x":576,"y":487},{"type":"mousemove","time":20144,"x":590,"y":484},{"type":"mouseup","time":20307,"x":590,"y":484},{"time":20308,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":20351,"x":484,"y":432},{"type":"mousemove","time":20556,"x":377,"y":371},{"type":"mousewheel","time":20610,"x":377,"y":371,"deltaY":-1},{"type":"mousewheel","time":20645,"x":377,"y":371,"deltaY":-22},{"type":"mousewheel","time":20668,"x":377,"y":371,"deltaY":-25},{"type":"mousewheel","time":20692,"x":377,"y":371,"deltaY":-79},{"type":"mousewheel","time":20715,"x":377,"y":371,"deltaY":-57},{"type":"mousewheel","time":20751,"x":377,"y":371,"deltaY":-198},{"type":"mousewheel","time":20777,"x":377,"y":371,"deltaY":-65},{"type":"mousewheel","time":20802,"x":377,"y":371,"deltaY":-127},{"type":"mousewheel","time":20831,"x":377,"y":371,"deltaY":-61},{"type":"mousewheel","time":20870,"x":377,"y":371,"deltaY":-110},{"type":"mousewheel","time":20899,"x":377,"y":371,"deltaY":-137},{"type":"mousemove","time":20968,"x":375,"y":366},{"type":"mousemove","time":21180,"x":186,"y":162},{"type":"mousemove","time":21392,"x":179,"y":169},{"type":"mousemove","time":21593,"x":172,"y":188},{"type":"mousemove","time":21801,"x":172,"y":192},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"2","time":22790,"target":"select"},{"time":22791,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":22843,"x":261,"y":292},{"type":"mousemove","time":23053,"x":294,"y":423},{"type":"mousewheel","time":23939,"x":294,"y":423,"deltaY":1},{"type":"mousewheel","time":23971,"x":294,"y":423,"deltaY":6},{"type":"mousewheel","time":23995,"x":294,"y":423,"deltaY":5},{"type":"mousewheel","time":24017,"x":294,"y":423,"deltaY":4},{"type":"mousewheel","time":24041,"x":294,"y":423,"deltaY":5},{"type":"mousewheel","time":24077,"x":294,"y":423,"deltaY":3},{"type":"mousewheel","time":24101,"x":294,"y":423,"deltaY":1},{"type":"mousewheel","time":24129,"x":294,"y":423,"deltaY":2},{"type":"mousewheel","time":24151,"x":294,"y":423,"deltaY":2},{"type":"mousewheel","time":24184,"x":294,"y":423,"deltaY":1},{"type":"mousewheel","time":24206,"x":294,"y":423,"deltaY":2},{"type":"mousemove","time":24440,"x":294,"y":424},{"type":"mousemove","time":24653,"x":363,"y":591},{"type":"mousemove","time":24856,"x":358,"y":569},{"type":"mousemove","time":25056,"x":358,"y":559},{"type":"mousemove","time":25266,"x":357,"y":553},{"type":"mousemove","time":25515,"x":357,"y":552},{"type":"mousedown","time":25525,"x":357,"y":552},{"type":"mousemove","time":25535,"x":357,"y":552},{"type":"mousemove","time":25735,"x":162,"y":554},{"type":"mousemove","time":25946,"x":141,"y":557},{"type":"mousemove","time":26253,"x":141,"y":557},{"type":"mousemove","time":26461,"x":484,"y":513},{"type":"mousemove","time":26662,"x":579,"y":496},{"type":"mousemove","time":26864,"x":609,"y":483},{"type":"mousemove","time":27081,"x":609,"y":485},{"type":"mousemove","time":27169,"x":609,"y":485},{"type":"mousemove","time":27379,"x":654,"y":484},{"type":"mouseup","time":27547,"x":654,"y":484},{"time":27548,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":27558,"x":606,"y":465},{"type":"mousemove","time":27763,"x":495,"y":413},{"type":"mousewheel","time":27851,"x":495,"y":413,"deltaY":-1},{"type":"mousewheel","time":27888,"x":495,"y":413,"deltaY":-18},{"type":"mousewheel","time":27910,"x":495,"y":413,"deltaY":-17},{"type":"mousewheel","time":27932,"x":495,"y":413,"deltaY":-79},{"type":"mousewheel","time":27955,"x":495,"y":413,"deltaY":-56},{"type":"mousewheel","time":27988,"x":495,"y":413,"deltaY":-135},{"type":"mousewheel","time":28011,"x":495,"y":413,"deltaY":-68},{"type":"mousewheel","time":28034,"x":495,"y":413,"deltaY":-130},{"type":"mousewheel","time":28058,"x":495,"y":413,"deltaY":-67},{"type":"mousewheel","time":28092,"x":495,"y":413,"deltaY":-123},{"type":"mousewheel","time":28115,"x":495,"y":413,"deltaY":-106},{"type":"mousewheel","time":28136,"x":495,"y":413,"deltaY":-47},{"type":"mousewheel","time":28162,"x":495,"y":413,"deltaY":-45},{"type":"mousewheel","time":28184,"x":495,"y":413,"deltaY":-78},{"type":"mousewheel","time":28206,"x":495,"y":413,"deltaY":-35},{"type":"mousewheel","time":28226,"x":495,"y":413,"deltaY":-61},{"type":"mousewheel","time":28245,"x":495,"y":413,"deltaY":-26},{"type":"mousewheel","time":28264,"x":495,"y":413,"deltaY":-24},{"type":"mousewheel","time":28283,"x":495,"y":413,"deltaY":-22},{"type":"mousewheel","time":28301,"x":495,"y":413,"deltaY":-20},{"type":"mousewheel","time":28320,"x":495,"y":413,"deltaY":-19},{"type":"mousewheel","time":28338,"x":495,"y":413,"deltaY":-17},{"type":"mousewheel","time":28358,"x":495,"y":413,"deltaY":-16},{"type":"mousemove","time":28391,"x":491,"y":412},{"type":"mousemove","time":28603,"x":188,"y":238},{"type":"mousemove","time":28815,"x":178,"y":211},{"type":"mousemove","time":29017,"x":178,"y":197},{"type":"mousemove","time":29227,"x":178,"y":195},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"3","time":30093,"target":"select"},{"time":30094,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":30116,"x":189,"y":276},{"type":"mousemove","time":30329,"x":210,"y":333},{"type":"mousewheel","time":30815,"x":210,"y":333,"deltaY":1},{"type":"mousewheel","time":30849,"x":210,"y":333,"deltaY":9},{"type":"mousewheel","time":30873,"x":210,"y":333,"deltaY":10},{"type":"mousewheel","time":30899,"x":210,"y":333,"deltaY":26},{"type":"mousewheel","time":30924,"x":210,"y":333,"deltaY":10},{"type":"mousewheel","time":30957,"x":210,"y":333,"deltaY":13},{"type":"mousewheel","time":30984,"x":210,"y":333,"deltaY":5},{"type":"mousewheel","time":31008,"x":210,"y":333,"deltaY":1},{"type":"mousewheel","time":31030,"x":210,"y":333,"deltaY":1},{"type":"mousemove","time":31348,"x":211,"y":341},{"type":"mousemove","time":31559,"x":235,"y":497},{"type":"mousemove","time":31765,"x":229,"y":522},{"type":"mousemove","time":31977,"x":221,"y":543},{"type":"mousemove","time":32098,"x":221,"y":545},{"type":"mousemove","time":32311,"x":221,"y":549},{"type":"mousedown","time":32642,"x":221,"y":549},{"type":"mousemove","time":32652,"x":219,"y":549},{"type":"mousemove","time":32853,"x":120,"y":569},{"type":"mousemove","time":33061,"x":113,"y":569},{"type":"mousemove","time":33264,"x":113,"y":569},{"type":"mousemove","time":33476,"x":510,"y":503},{"type":"mousemove","time":33693,"x":519,"y":502},{"type":"mousemove","time":33997,"x":519,"y":501},{"type":"mouseup","time":34472,"x":519,"y":501},{"time":34473,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":34483,"x":486,"y":491},{"type":"mousemove","time":34692,"x":330,"y":424},{"type":"mousewheel","time":35012,"x":330,"y":424,"deltaY":-1},{"type":"mousewheel","time":35044,"x":330,"y":424,"deltaY":-25},{"type":"mousewheel","time":35068,"x":330,"y":424,"deltaY":-28},{"type":"mousewheel","time":35091,"x":330,"y":424,"deltaY":-38},{"type":"mousewheel","time":35114,"x":330,"y":424,"deltaY":-28},{"type":"mousewheel","time":35147,"x":330,"y":424,"deltaY":-44},{"type":"mousewheel","time":35173,"x":330,"y":424,"deltaY":-160},{"type":"mousewheel","time":35196,"x":330,"y":424,"deltaY":-106},{"type":"mousewheel","time":35221,"x":330,"y":424,"deltaY":-55},{"type":"mousewheel","time":35254,"x":330,"y":424,"deltaY":-102},{"type":"mousewheel","time":35277,"x":330,"y":424,"deltaY":-88},{"type":"mousewheel","time":35300,"x":330,"y":424,"deltaY":-39},{"type":"mousewheel","time":35324,"x":330,"y":424,"deltaY":-69},{"type":"mousewheel","time":35360,"x":330,"y":424,"deltaY":-58},{"type":"mousewheel","time":35385,"x":330,"y":424,"deltaY":-26},{"type":"mousewheel","time":35411,"x":330,"y":424,"deltaY":-45},{"type":"mousewheel","time":35439,"x":330,"y":424,"deltaY":-19},{"type":"mousewheel","time":35476,"x":330,"y":424,"deltaY":-33},{"type":"mousewheel","time":35501,"x":330,"y":424,"deltaY":-27},{"type":"mousewheel","time":35528,"x":330,"y":424,"deltaY":-24},{"type":"mousewheel","time":35554,"x":330,"y":424,"deltaY":-10},{"type":"mousewheel","time":35583,"x":330,"y":424,"deltaY":-18},{"type":"mousewheel","time":35608,"x":330,"y":424,"deltaY":-15},{"type":"mousewheel","time":35631,"x":330,"y":424,"deltaY":-7},{"type":"mousewheel","time":35654,"x":330,"y":424,"deltaY":-6},{"type":"mousewheel","time":35676,"x":330,"y":424,"deltaY":-11},{"type":"mousewheel","time":35703,"x":330,"y":424,"deltaY":-5},{"type":"mousewheel","time":35727,"x":330,"y":424,"deltaY":-9},{"type":"mousewheel","time":35749,"x":330,"y":424,"deltaY":-4},{"type":"mousewheel","time":35771,"x":330,"y":424,"deltaY":-4},{"type":"mousewheel","time":35794,"x":330,"y":424,"deltaY":-6},{"type":"mousewheel","time":35816,"x":330,"y":424,"deltaY":-3},{"type":"mousewheel","time":35838,"x":330,"y":424,"deltaY":-3},{"type":"mousewheel","time":35860,"x":330,"y":424,"deltaY":-4},{"type":"mousewheel","time":35885,"x":330,"y":424,"deltaY":-2},{"type":"mousewheel","time":35906,"x":330,"y":424,"deltaY":-2},{"type":"mousewheel","time":35945,"x":330,"y":424,"deltaY":-1},{"type":"mousewheel","time":35967,"x":330,"y":424,"deltaY":-11},{"type":"mousewheel","time":35988,"x":330,"y":424,"deltaY":-10},{"type":"mousewheel","time":36013,"x":330,"y":424,"deltaY":-26},{"type":"mousewheel","time":36035,"x":330,"y":424,"deltaY":-16},{"type":"mousewheel","time":36057,"x":330,"y":424,"deltaY":-34},{"type":"mousewheel","time":36079,"x":330,"y":424,"deltaY":-35},{"type":"mousewheel","time":36104,"x":330,"y":424,"deltaY":-16},{"type":"mousewheel","time":36126,"x":330,"y":424,"deltaY":-35},{"type":"mousewheel","time":36147,"x":330,"y":424,"deltaY":-16},{"type":"mousemove","time":36198,"x":329,"y":421},{"type":"mousemove","time":36398,"x":191,"y":233},{"type":"mousemove","time":36613,"x":190,"y":210},{"type":"mousemove","time":36826,"x":190,"y":192},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(4)>select.test-inputs-select-select","value":"0","time":38232,"target":"select"},{"time":38233,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":38245,"x":244,"y":134},{"type":"mousemove","time":38446,"x":352,"y":166},{"type":"mousemove","time":38672,"x":352,"y":166},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(2)>select.test-inputs-select-select","value":"0","time":39754,"target":"select"},{"time":39755,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":39845,"x":502,"y":178},{"type":"mousemove","time":40071,"x":522,"y":160},{"type":"mousemove","time":40291,"x":522,"y":160},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select:nth-child(3)>select.test-inputs-select-select","value":"0","time":41385,"target":"select"},{"time":41386,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":41426,"x":465,"y":156},{"type":"mousemove","time":41626,"x":207,"y":174},{"type":"mousemove","time":41838,"x":207,"y":173},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"1","time":43065,"target":"select"},{"time":43066,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":43142,"x":306,"y":239},{"type":"mousemove","time":43354,"x":320,"y":247},{"type":"mousemove","time":43659,"x":321,"y":248},{"type":"mousemove","time":43859,"x":369,"y":445},{"type":"mousemove","time":44068,"x":380,"y":369},{"type":"mousewheel","time":44192,"x":380,"y":369,"deltaY":1},{"type":"mousewheel","time":44217,"x":380,"y":369,"deltaY":5},{"type":"mousewheel","time":44239,"x":380,"y":369,"deltaY":16},{"type":"mousewheel","time":44261,"x":380,"y":369,"deltaY":6},{"type":"mousewheel","time":44283,"x":380,"y":369,"deltaY":7},{"type":"mousewheel","time":44305,"x":380,"y":369,"deltaY":8},{"type":"mousewheel","time":44331,"x":380,"y":369,"deltaY":1},{"type":"mousewheel","time":44353,"x":380,"y":369,"deltaY":2},{"type":"mousewheel","time":44458,"x":380,"y":369,"deltaY":-1},{"type":"mousewheel","time":44481,"x":380,"y":369,"deltaY":-1},{"type":"mousewheel","time":44504,"x":380,"y":369,"deltaY":-5},{"type":"mousewheel","time":44528,"x":380,"y":369,"deltaY":-3},{"type":"mousewheel","time":44557,"x":380,"y":369,"deltaY":-6},{"type":"mousewheel","time":44580,"x":380,"y":369,"deltaY":-3},{"type":"mousewheel","time":44607,"x":380,"y":369,"deltaY":-2},{"type":"mousewheel","time":44631,"x":380,"y":369,"deltaY":-2},{"type":"mousewheel","time":44677,"x":380,"y":369,"deltaY":-1},{"type":"mousewheel","time":44700,"x":380,"y":369,"deltaY":-2},{"type":"mousewheel","time":44723,"x":380,"y":369,"deltaY":-3},{"type":"mousewheel","time":44747,"x":380,"y":369,"deltaY":-4},{"type":"mousewheel","time":44777,"x":380,"y":369,"deltaY":-10},{"type":"mousewheel","time":44801,"x":380,"y":369,"deltaY":-4},{"type":"mousewheel","time":44826,"x":380,"y":369,"deltaY":-4},{"type":"mousewheel","time":44850,"x":380,"y":369,"deltaY":-1},{"type":"mousewheel","time":44873,"x":380,"y":369,"deltaY":-2},{"type":"mousewheel","time":44896,"x":380,"y":369,"deltaY":-2},{"type":"mousewheel","time":44923,"x":380,"y":369,"deltaY":-1},{"type":"mousemove","time":45075,"x":380,"y":366},{"type":"mousemove","time":45276,"x":354,"y":240},{"type":"mousemove","time":45486,"x":350,"y":211},{"type":"mousedown","time":45602,"x":349,"y":211},{"type":"mouseup","time":45735,"x":349,"y":211},{"time":45736,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":45768,"x":349,"y":211},{"type":"mousemove","time":45841,"x":349,"y":212},{"type":"mousedown","time":46021,"x":349,"y":226},{"type":"mousemove","time":46058,"x":349,"y":226},{"type":"mouseup","time":46155,"x":349,"y":226},{"time":46156,"delay":400,"type":"screenshot-auto"},{"type":"mousedown","time":46555,"x":349,"y":226},{"type":"mouseup","time":46686,"x":349,"y":226},{"time":46687,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":47057,"x":349,"y":226},{"type":"mousemove","time":47258,"x":273,"y":232},{"type":"mousemove","time":47467,"x":202,"y":240},{"type":"mousemove","time":47674,"x":150,"y":178},{"type":"mousemove","time":47874,"x":143,"y":166},{"type":"mousemove","time":48085,"x":143,"y":163},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"2","time":48977,"target":"select"},{"time":48978,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":49026,"x":297,"y":259},{"type":"mousemove","time":49238,"x":356,"y":288},{"type":"mousewheel","time":49857,"x":387,"y":315,"deltaY":1},{"type":"mousewheel","time":49884,"x":387,"y":315,"deltaY":2},{"type":"mousewheel","time":49907,"x":387,"y":315,"deltaY":9},{"type":"mousewheel","time":49930,"x":387,"y":315,"deltaY":4},{"type":"mousewheel","time":49953,"x":387,"y":315,"deltaY":9},{"type":"mousewheel","time":49977,"x":387,"y":315,"deltaY":4},{"type":"mousewheel","time":50010,"x":387,"y":315,"deltaY":6},{"type":"mousewheel","time":50033,"x":387,"y":315,"deltaY":3},{"type":"mousewheel","time":50060,"x":387,"y":315,"deltaY":4},{"type":"mousewheel","time":50083,"x":387,"y":315,"deltaY":1},{"type":"mousewheel","time":50112,"x":387,"y":315,"deltaY":2},{"type":"mousewheel","time":50137,"x":387,"y":315,"deltaY":0},{"type":"mousewheel","time":50160,"x":387,"y":315,"deltaY":0},{"type":"mousewheel","time":50242,"x":387,"y":315,"deltaY":-1},{"type":"mousewheel","time":50270,"x":387,"y":315,"deltaY":-1},{"type":"mousewheel","time":50296,"x":387,"y":315,"deltaY":-2},{"type":"mousewheel","time":50320,"x":387,"y":315,"deltaY":-4},{"type":"mousewheel","time":50349,"x":387,"y":315,"deltaY":-3},{"type":"mousewheel","time":50376,"x":387,"y":315,"deltaY":-5},{"type":"mousewheel","time":50402,"x":387,"y":315,"deltaY":-4},{"type":"mousewheel","time":50429,"x":387,"y":315,"deltaY":-19},{"type":"mousewheel","time":50462,"x":387,"y":315,"deltaY":-24},{"type":"mousewheel","time":50498,"x":387,"y":315,"deltaY":-12},{"type":"mousewheel","time":50528,"x":387,"y":315,"deltaY":-4},{"type":"mousewheel","time":50567,"x":387,"y":315,"deltaY":-4},{"type":"mousewheel","time":50599,"x":387,"y":315,"deltaY":-20},{"type":"mousewheel","time":50626,"x":387,"y":315,"deltaY":-9},{"type":"mousewheel","time":50654,"x":387,"y":315,"deltaY":-5},{"type":"mousemove","time":50668,"x":388,"y":307},{"type":"mousemove","time":50871,"x":388,"y":248},{"type":"mousemove","time":51078,"x":382,"y":224},{"type":"mousedown","time":51160,"x":381,"y":224},{"type":"mouseup","time":51287,"x":381,"y":224},{"time":51288,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":51315,"x":381,"y":224},{"type":"mousedown","time":51469,"x":381,"y":224},{"type":"mouseup","time":51568,"x":381,"y":224},{"time":51569,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":51780,"x":377,"y":224},{"type":"mousemove","time":51987,"x":216,"y":175},{"type":"mousemove","time":52202,"x":211,"y":167},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"3","time":53223,"target":"select"},{"time":53224,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":53256,"x":211,"y":216},{"type":"mousemove","time":53456,"x":311,"y":206},{"type":"mousemove","time":53656,"x":215,"y":187},{"type":"mousemove","time":53856,"x":204,"y":163},{"type":"mousemove","time":54068,"x":204,"y":163},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"4","time":55050,"target":"select"},{"time":55051,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":55092,"x":217,"y":187},{"type":"mousemove","time":55292,"x":260,"y":315},{"type":"mousemove","time":55500,"x":262,"y":316},{"type":"mousewheel","time":55823,"x":262,"y":316,"deltaY":1},{"type":"mousewheel","time":55853,"x":262,"y":316,"deltaY":16},{"type":"mousewheel","time":55878,"x":262,"y":316,"deltaY":23},{"type":"mousewheel","time":55903,"x":262,"y":316,"deltaY":35},{"type":"mousewheel","time":55927,"x":262,"y":316,"deltaY":24},{"type":"mousewheel","time":55963,"x":262,"y":316,"deltaY":56},{"type":"mousewheel","time":55989,"x":262,"y":316,"deltaY":56},{"type":"mousewheel","time":56014,"x":262,"y":316,"deltaY":27},{"type":"mousewheel","time":56042,"x":262,"y":316,"deltaY":60},{"type":"mousewheel","time":56087,"x":262,"y":316,"deltaY":51},{"type":"mousewheel","time":56116,"x":262,"y":316,"deltaY":42},{"type":"mousewheel","time":56142,"x":262,"y":316,"deltaY":36},{"type":"mousewheel","time":56184,"x":262,"y":316,"deltaY":30},{"type":"mousewheel","time":56211,"x":262,"y":316,"deltaY":25},{"type":"mousewheel","time":56237,"x":262,"y":316,"deltaY":21},{"type":"mousewheel","time":56263,"x":262,"y":316,"deltaY":9},{"type":"mousewheel","time":56301,"x":262,"y":316,"deltaY":16},{"type":"mousewheel","time":56329,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":56377,"x":262,"y":316,"deltaY":1},{"type":"mousewheel","time":56413,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":56439,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":56466,"x":262,"y":316,"deltaY":3},{"type":"mousewheel","time":56493,"x":262,"y":316,"deltaY":6},{"type":"mousewheel","time":56531,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":56558,"x":262,"y":316,"deltaY":5},{"type":"mousewheel","time":56584,"x":262,"y":316,"deltaY":1},{"type":"mousewheel","time":56614,"x":262,"y":316,"deltaY":5},{"type":"mousewheel","time":56649,"x":262,"y":316,"deltaY":16},{"type":"mousewheel","time":56676,"x":262,"y":316,"deltaY":15},{"type":"mousewheel","time":56702,"x":262,"y":316,"deltaY":8},{"type":"mousewheel","time":56732,"x":262,"y":316,"deltaY":27},{"type":"mousewheel","time":56765,"x":262,"y":316,"deltaY":16},{"type":"mousewheel","time":56791,"x":262,"y":316,"deltaY":9},{"type":"mousewheel","time":56817,"x":262,"y":316,"deltaY":16},{"type":"mousewheel","time":56849,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":56883,"x":262,"y":316,"deltaY":17},{"type":"mousewheel","time":56909,"x":262,"y":316,"deltaY":5},{"type":"mousewheel","time":56936,"x":262,"y":316,"deltaY":9},{"type":"mousewheel","time":56963,"x":262,"y":316,"deltaY":4},{"type":"mousewheel","time":57012,"x":262,"y":316,"deltaY":1},{"type":"mousewheel","time":57044,"x":262,"y":316,"deltaY":10},{"type":"mousewheel","time":57074,"x":262,"y":316,"deltaY":15},{"type":"mousewheel","time":57107,"x":262,"y":316,"deltaY":16},{"type":"mousewheel","time":57133,"x":262,"y":316,"deltaY":5},{"type":"mousewheel","time":57159,"x":262,"y":316,"deltaY":8},{"type":"mousewheel","time":57186,"x":262,"y":316,"deltaY":5},{"type":"mousewheel","time":57218,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":57244,"x":262,"y":316,"deltaY":8},{"type":"mousewheel","time":57268,"x":262,"y":316,"deltaY":35},{"type":"mousewheel","time":57296,"x":262,"y":316,"deltaY":8},{"type":"mousewheel","time":57327,"x":262,"y":316,"deltaY":17},{"type":"mousewheel","time":57353,"x":262,"y":316,"deltaY":15},{"type":"mousewheel","time":57377,"x":262,"y":316,"deltaY":6},{"type":"mousewheel","time":57402,"x":262,"y":316,"deltaY":11},{"type":"mousewheel","time":57434,"x":262,"y":316,"deltaY":5},{"type":"mousewheel","time":57589,"x":262,"y":316,"deltaY":1},{"type":"mousewheel","time":57613,"x":262,"y":316,"deltaY":3},{"type":"mousewheel","time":57643,"x":262,"y":316,"deltaY":7},{"type":"mousewheel","time":57668,"x":262,"y":316,"deltaY":3},{"type":"mousewheel","time":57693,"x":262,"y":316,"deltaY":2},{"type":"mousewheel","time":57719,"x":262,"y":316,"deltaY":1},{"type":"mousewheel","time":57755,"x":262,"y":316,"deltaY":-3},{"type":"mousewheel","time":57783,"x":262,"y":316,"deltaY":-10},{"type":"mousewheel","time":57811,"x":262,"y":316,"deltaY":-9},{"type":"mousewheel","time":57837,"x":262,"y":316,"deltaY":-6},{"type":"mousewheel","time":57869,"x":262,"y":316,"deltaY":-3},{"type":"mousewheel","time":57896,"x":262,"y":316,"deltaY":-5},{"type":"mousewheel","time":57923,"x":262,"y":316,"deltaY":-1},{"type":"mousemove","time":58039,"x":262,"y":316},{"type":"mousemove","time":58241,"x":397,"y":247},{"type":"mousemove","time":58455,"x":399,"y":227},{"type":"mousedown","time":58567,"x":397,"y":226},{"type":"mouseup","time":58664,"x":397,"y":226},{"time":58665,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":58711,"x":397,"y":226},{"type":"mousedown","time":59083,"x":397,"y":226},{"type":"mouseup","time":59183,"x":397,"y":226},{"time":59184,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":59305,"x":397,"y":229},{"type":"mousemove","time":59518,"x":364,"y":357},{"type":"mousewheel","time":59622,"x":364,"y":357,"deltaY":-1},{"type":"mousewheel","time":59655,"x":364,"y":357,"deltaY":-13},{"type":"mousewheel","time":59682,"x":364,"y":357,"deltaY":-14},{"type":"mousewheel","time":59710,"x":364,"y":357,"deltaY":-82},{"type":"mousewheel","time":59737,"x":364,"y":357,"deltaY":-40},{"type":"mousewheel","time":59777,"x":364,"y":357,"deltaY":-67},{"type":"mousewheel","time":59809,"x":364,"y":357,"deltaY":-302},{"type":"mousewheel","time":59835,"x":364,"y":357,"deltaY":-143},{"type":"mousewheel","time":59862,"x":364,"y":357,"deltaY":-68},{"type":"mousewheel","time":59898,"x":364,"y":357,"deltaY":-124},{"type":"mousewheel","time":59926,"x":364,"y":357,"deltaY":-109},{"type":"mousewheel","time":59955,"x":364,"y":357,"deltaY":-94},{"type":"mousewheel","time":59983,"x":364,"y":357,"deltaY":-79},{"type":"mousewheel","time":60022,"x":364,"y":357,"deltaY":-35},{"type":"mousewheel","time":60075,"x":364,"y":357,"deltaY":-2},{"type":"mousewheel","time":60101,"x":364,"y":357,"deltaY":-12},{"type":"mousewheel","time":60139,"x":364,"y":357,"deltaY":-49},{"type":"mousewheel","time":60167,"x":364,"y":357,"deltaY":-40},{"type":"mousewheel","time":60195,"x":364,"y":357,"deltaY":-185},{"type":"mousewheel","time":60225,"x":364,"y":357,"deltaY":-44},{"type":"mousewheel","time":60266,"x":364,"y":357,"deltaY":-133},{"type":"mousewheel","time":60305,"x":364,"y":357,"deltaY":-74},{"type":"mousewheel","time":60335,"x":364,"y":357,"deltaY":-63},{"type":"mousewheel","time":60386,"x":364,"y":357,"deltaY":-76},{"type":"mousewheel","time":60422,"x":364,"y":357,"deltaY":-40},{"type":"mousewheel","time":60454,"x":364,"y":357,"deltaY":-33},{"type":"mousewheel","time":60509,"x":364,"y":357,"deltaY":-39},{"type":"mousewheel","time":60552,"x":364,"y":357,"deltaY":-21},{"type":"mousewheel","time":60591,"x":364,"y":357,"deltaY":-26},{"type":"mousewheel","time":60632,"x":364,"y":357,"deltaY":-20},{"type":"mousewheel","time":60665,"x":364,"y":357,"deltaY":-11},{"type":"mousewheel","time":60698,"x":364,"y":357,"deltaY":-10},{"type":"mousewheel","time":60739,"x":364,"y":357,"deltaY":-8},{"type":"mousewheel","time":60777,"x":364,"y":357,"deltaY":-7},{"type":"mousewheel","time":60811,"x":364,"y":357,"deltaY":-6},{"type":"mousewheel","time":60858,"x":364,"y":357,"deltaY":-7},{"type":"mousewheel","time":60892,"x":364,"y":357,"deltaY":-4},{"type":"mousewheel","time":60925,"x":364,"y":357,"deltaY":-2},{"type":"mousewheel","time":60966,"x":364,"y":357,"deltaY":-3},{"type":"mousewheel","time":61001,"x":364,"y":357,"deltaY":-1},{"type":"mousewheel","time":61039,"x":364,"y":357,"deltaY":-2},{"type":"mousemove","time":61425,"x":361,"y":355},{"type":"mousemove","time":61625,"x":210,"y":192},{"type":"mousemove","time":61855,"x":204,"y":191},{"type":"mousemove","time":63272,"x":264,"y":180},{"type":"mousemove","time":63484,"x":232,"y":171},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"5","time":64793,"target":"select"},{"time":64794,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":64813,"x":339,"y":266},{"type":"mousewheel","time":65771,"x":339,"y":266,"deltaY":1},{"type":"mousewheel","time":65824,"x":339,"y":266,"deltaY":7},{"type":"mousewheel","time":65857,"x":339,"y":266,"deltaY":5},{"type":"mousewheel","time":65888,"x":339,"y":266,"deltaY":3},{"type":"mousewheel","time":65931,"x":339,"y":266,"deltaY":6},{"type":"mousewheel","time":65962,"x":339,"y":266,"deltaY":2},{"type":"mousewheel","time":65992,"x":339,"y":266,"deltaY":1},{"type":"mousewheel","time":66488,"x":339,"y":266,"deltaY":1},{"type":"mousewheel","time":66541,"x":339,"y":266,"deltaY":3},{"type":"mousewheel","time":66578,"x":339,"y":266,"deltaY":9},{"type":"mousewheel","time":66616,"x":339,"y":266,"deltaY":6},{"type":"mousewheel","time":66672,"x":339,"y":266,"deltaY":6},{"type":"mousewheel","time":66710,"x":339,"y":266,"deltaY":4},{"type":"mousewheel","time":66746,"x":339,"y":266,"deltaY":4},{"type":"mousemove","time":66921,"x":339,"y":267},{"type":"mousemove","time":67145,"x":360,"y":360},{"type":"mousewheel","time":67338,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":67373,"x":360,"y":360,"deltaY":11},{"type":"mousewheel","time":67402,"x":360,"y":360,"deltaY":6},{"type":"mousewheel","time":67435,"x":360,"y":360,"deltaY":18},{"type":"mousewheel","time":67465,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":67505,"x":360,"y":360,"deltaY":4},{"type":"mousewheel","time":67535,"x":360,"y":360,"deltaY":6},{"type":"mousewheel","time":67565,"x":360,"y":360,"deltaY":24},{"type":"mousewheel","time":67609,"x":360,"y":360,"deltaY":18},{"type":"mousewheel","time":67638,"x":360,"y":360,"deltaY":10},{"type":"mousewheel","time":67668,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":67710,"x":360,"y":360,"deltaY":4},{"type":"mousewheel","time":67754,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":67780,"x":360,"y":360,"deltaY":6},{"type":"mousewheel","time":67823,"x":360,"y":360,"deltaY":14},{"type":"mousewheel","time":67851,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":67877,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":67905,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":67944,"x":360,"y":360,"deltaY":7},{"type":"mousewheel","time":67973,"x":360,"y":360,"deltaY":28},{"type":"mousewheel","time":68001,"x":360,"y":360,"deltaY":15},{"type":"mousewheel","time":68044,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":68073,"x":360,"y":360,"deltaY":11},{"type":"mousewheel","time":68101,"x":360,"y":360,"deltaY":10},{"type":"mousewheel","time":68128,"x":360,"y":360,"deltaY":4},{"type":"mousewheel","time":68197,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":68226,"x":360,"y":360,"deltaY":2},{"type":"mousewheel","time":68267,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":68296,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":68323,"x":360,"y":360,"deltaY":10},{"type":"mousewheel","time":68351,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":68388,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":68416,"x":360,"y":360,"deltaY":3},{"type":"mousewheel","time":68444,"x":360,"y":360,"deltaY":4},{"type":"mousewheel","time":68477,"x":360,"y":360,"deltaY":4},{"type":"mousewheel","time":68754,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":68788,"x":360,"y":360,"deltaY":10},{"type":"mousewheel","time":68815,"x":360,"y":360,"deltaY":8},{"type":"mousewheel","time":68842,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":68868,"x":360,"y":360,"deltaY":8},{"type":"mousewheel","time":68923,"x":360,"y":360,"deltaY":9},{"type":"mousewheel","time":68952,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":68982,"x":360,"y":360,"deltaY":10},{"type":"mousewheel","time":69029,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":69061,"x":360,"y":360,"deltaY":2},{"type":"mousewheel","time":69288,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":69329,"x":360,"y":360,"deltaY":2},{"type":"mousewheel","time":69362,"x":360,"y":360,"deltaY":8},{"type":"mousewheel","time":69390,"x":360,"y":360,"deltaY":11},{"type":"mousewheel","time":69430,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":69466,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":69496,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":69539,"x":360,"y":360,"deltaY":6},{"type":"mousewheel","time":69568,"x":360,"y":360,"deltaY":7},{"type":"mousewheel","time":69598,"x":360,"y":360,"deltaY":28},{"type":"mousewheel","time":69640,"x":360,"y":360,"deltaY":22},{"type":"mousewheel","time":69674,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":69723,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":69762,"x":360,"y":360,"deltaY":3},{"type":"mousewheel","time":69792,"x":360,"y":360,"deltaY":11},{"type":"mousewheel","time":69822,"x":360,"y":360,"deltaY":13},{"type":"mousewheel","time":69852,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":69893,"x":360,"y":360,"deltaY":15},{"type":"mousewheel","time":69922,"x":360,"y":360,"deltaY":11},{"type":"mousewheel","time":69950,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":69977,"x":360,"y":360,"deltaY":3},{"type":"mousewheel","time":70014,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":70042,"x":360,"y":360,"deltaY":20},{"type":"mousewheel","time":70070,"x":360,"y":360,"deltaY":9},{"type":"mousewheel","time":70097,"x":360,"y":360,"deltaY":9},{"type":"mousewheel","time":70138,"x":360,"y":360,"deltaY":8},{"type":"mousewheel","time":70165,"x":360,"y":360,"deltaY":3},{"type":"mousewheel","time":70224,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":70259,"x":360,"y":360,"deltaY":1},{"type":"mousewheel","time":70290,"x":360,"y":360,"deltaY":8},{"type":"mousewheel","time":70328,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":70380,"x":360,"y":360,"deltaY":12},{"type":"mousewheel","time":70436,"x":360,"y":360,"deltaY":2},{"type":"mousewheel","time":70488,"x":360,"y":360,"deltaY":8},{"type":"mousewheel","time":70528,"x":360,"y":360,"deltaY":5},{"type":"mousewheel","time":70569,"x":360,"y":360,"deltaY":2},{"type":"mousewheel","time":70637,"x":360,"y":360,"deltaY":-1},{"type":"mousemove","time":70741,"x":351,"y":358},{"type":"mousemove","time":70956,"x":196,"y":358},{"type":"mousemove","time":71163,"x":213,"y":380},{"type":"mousewheel","time":71292,"x":213,"y":380,"deltaY":-1},{"type":"mousewheel","time":71328,"x":213,"y":380,"deltaY":-5},{"type":"mousewheel","time":71363,"x":213,"y":380,"deltaY":-25},{"type":"mousewheel","time":71394,"x":213,"y":380,"deltaY":-54},{"type":"mousewheel","time":71433,"x":213,"y":380,"deltaY":-24},{"type":"mousewheel","time":71469,"x":213,"y":380,"deltaY":-35},{"type":"mousewheel","time":71499,"x":213,"y":380,"deltaY":-208},{"type":"mousewheel","time":71542,"x":213,"y":380,"deltaY":-88},{"type":"mousewheel","time":71574,"x":213,"y":380,"deltaY":-76},{"type":"mousewheel","time":71604,"x":213,"y":380,"deltaY":-64},{"type":"mousewheel","time":71664,"x":213,"y":380,"deltaY":-2},{"type":"mousewheel","time":71699,"x":213,"y":380,"deltaY":-21},{"type":"mousewheel","time":71729,"x":213,"y":380,"deltaY":-82},{"type":"mousewheel","time":71768,"x":213,"y":380,"deltaY":-31},{"type":"mousewheel","time":71804,"x":213,"y":380,"deltaY":-49},{"type":"mousewheel","time":71835,"x":213,"y":380,"deltaY":-299},{"type":"mousewheel","time":71878,"x":213,"y":380,"deltaY":-119},{"type":"mousewheel","time":71917,"x":213,"y":380,"deltaY":-106},{"type":"mousewheel","time":71952,"x":213,"y":380,"deltaY":-90},{"type":"mousewheel","time":72002,"x":213,"y":380,"deltaY":-111},{"type":"mousewheel","time":72067,"x":213,"y":380,"deltaY":-2},{"type":"mousewheel","time":72116,"x":213,"y":380,"deltaY":-86},{"type":"mousewheel","time":72154,"x":213,"y":380,"deltaY":-51},{"type":"mousewheel","time":72190,"x":213,"y":380,"deltaY":-237},{"type":"mousewheel","time":72234,"x":213,"y":380,"deltaY":-171},{"type":"mousewheel","time":72277,"x":213,"y":380,"deltaY":-102},{"type":"mousewheel","time":72313,"x":213,"y":380,"deltaY":-86},{"type":"mousewheel","time":72363,"x":213,"y":380,"deltaY":-105},{"type":"mousewheel","time":72397,"x":213,"y":380,"deltaY":-81},{"type":"mousewheel","time":72431,"x":213,"y":380,"deltaY":-42},{"type":"mousewheel","time":72480,"x":213,"y":380,"deltaY":-36},{"type":"mousewheel","time":72520,"x":213,"y":380,"deltaY":-43},{"type":"mousewheel","time":72556,"x":213,"y":380,"deltaY":-23},{"type":"mousewheel","time":72604,"x":213,"y":380,"deltaY":-27},{"type":"mousewheel","time":72645,"x":213,"y":380,"deltaY":-15},{"type":"mousewheel","time":72680,"x":213,"y":380,"deltaY":-19},{"type":"mousewheel","time":72723,"x":213,"y":380,"deltaY":-10},{"type":"mousewheel","time":72764,"x":213,"y":380,"deltaY":-9},{"type":"mousewheel","time":72799,"x":213,"y":380,"deltaY":-11},{"type":"mousewheel","time":72847,"x":213,"y":380,"deltaY":-6},{"type":"mousewheel","time":72886,"x":213,"y":380,"deltaY":-6},{"type":"mousewheel","time":72921,"x":213,"y":380,"deltaY":-4},{"type":"mousewheel","time":72974,"x":213,"y":380,"deltaY":-3},{"type":"mousewheel","time":73014,"x":213,"y":380,"deltaY":-2},{"type":"mousewheel","time":73050,"x":213,"y":380,"deltaY":-2},{"type":"mousewheel","time":73094,"x":213,"y":380,"deltaY":-1},{"type":"mousewheel","time":73246,"x":213,"y":380,"deltaY":-1},{"type":"mousewheel","time":73283,"x":213,"y":380,"deltaY":-3},{"type":"mousewheel","time":73333,"x":213,"y":380,"deltaY":-52},{"type":"mousewheel","time":73378,"x":213,"y":380,"deltaY":-42},{"type":"mousewheel","time":73419,"x":213,"y":380,"deltaY":-30},{"type":"mousewheel","time":73467,"x":213,"y":380,"deltaY":-217},{"type":"mousewheel","time":73509,"x":213,"y":380,"deltaY":-72},{"type":"mousewheel","time":73546,"x":213,"y":380,"deltaY":-61},{"type":"mousewheel","time":73597,"x":213,"y":380,"deltaY":-72},{"type":"mousewheel","time":73637,"x":213,"y":380,"deltaY":-56},{"type":"mousewheel","time":73675,"x":213,"y":380,"deltaY":-30},{"type":"mousewheel","time":73724,"x":213,"y":380,"deltaY":-36},{"type":"mousewheel","time":73761,"x":213,"y":380,"deltaY":-19},{"type":"mousewheel","time":73799,"x":213,"y":380,"deltaY":-23},{"type":"mousewheel","time":73855,"x":213,"y":380,"deltaY":-12},{"type":"mousewheel","time":73898,"x":213,"y":380,"deltaY":-15},{"type":"mousewheel","time":73942,"x":213,"y":380,"deltaY":-12},{"type":"mousewheel","time":73994,"x":213,"y":380,"deltaY":-3},{"type":"mousewheel","time":74098,"x":213,"y":380,"deltaY":-1},{"type":"mousewheel","time":74144,"x":213,"y":380,"deltaY":-34},{"type":"mousewheel","time":74187,"x":213,"y":380,"deltaY":-7},{"type":"mousewheel","time":74242,"x":213,"y":380,"deltaY":-10},{"type":"mousewheel","time":74284,"x":213,"y":380,"deltaY":-82},{"type":"mousewheel","time":74341,"x":213,"y":380,"deltaY":-30},{"type":"mousewheel","time":74398,"x":213,"y":380,"deltaY":-8},{"type":"mousemove","time":74415,"x":229,"y":374},{"type":"mousemove","time":74641,"x":360,"y":321},{"type":"mousemove","time":74867,"x":404,"y":237},{"type":"mousemove","time":75087,"x":400,"y":231},{"type":"mousemove","time":75302,"x":397,"y":228},{"type":"mousedown","time":75363,"x":395,"y":227},{"type":"mouseup","time":75519,"x":395,"y":227},{"time":75520,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":75557,"x":395,"y":227},{"type":"mousedown","time":76066,"x":395,"y":227},{"type":"mouseup","time":76167,"x":395,"y":227},{"time":76168,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":76351,"x":389,"y":235},{"type":"mousemove","time":76553,"x":231,"y":491},{"type":"mousemove","time":76756,"x":175,"y":563},{"type":"mousemove","time":76959,"x":165,"y":567},{"type":"mousemove","time":77187,"x":162,"y":568},{"type":"mousemove","time":77424,"x":161,"y":568},{"type":"mousedown","time":77551,"x":161,"y":568},{"type":"mousemove","time":77565,"x":157,"y":568},{"type":"mousemove","time":77795,"x":77,"y":570},{"type":"mouseup","time":78020,"x":77,"y":570},{"time":78021,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":78038,"x":104,"y":519},{"type":"mousemove","time":78276,"x":175,"y":360},{"type":"mousemove","time":78515,"x":174,"y":241},{"type":"mousemove","time":78738,"x":184,"y":201},{"type":"mousemove","time":78967,"x":186,"y":193},{"type":"mousemove","time":79205,"x":186,"y":193},{"type":"mousemove","time":79410,"x":181,"y":174},{"type":"mousemove","time":79637,"x":181,"y":172},{"type":"mousemove","time":79885,"x":181,"y":172},{"type":"valuechange","selector":"#main_time_axis>div.test-chart-block-left>div.test-inputs.test-buttons.test-inputs-style-compact>span.test-inputs-select>select.test-inputs-select-select","value":"6","time":80994,"target":"select"},{"time":80995,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":81006,"x":264,"y":318},{"type":"mousemove","time":81582,"x":295,"y":341},{"type":"mousemove","time":81833,"x":376,"y":359},{"type":"mousewheel","time":82320,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":82438,"x":376,"y":359,"deltaY":97},{"type":"mousewheel","time":82537,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":82579,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":82660,"x":376,"y":359,"deltaY":84},{"type":"mousewheel","time":82699,"x":376,"y":359,"deltaY":10},{"type":"mousewheel","time":82769,"x":376,"y":359,"deltaY":17},{"type":"mousewheel","time":82809,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":82872,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":82912,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":82974,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":83013,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":83076,"x":376,"y":359,"deltaY":3},{"type":"mousewheel","time":83695,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":83762,"x":376,"y":359,"deltaY":19},{"type":"mousewheel","time":83797,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":83830,"x":376,"y":359,"deltaY":6},{"type":"mousewheel","time":83887,"x":376,"y":359,"deltaY":5},{"type":"mousewheel","time":83919,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":84196,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":84254,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":84291,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":84326,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":84380,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":84580,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":84634,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":84669,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":84706,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":84757,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":84791,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":84827,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":84877,"x":376,"y":359,"deltaY":13},{"type":"mousewheel","time":84911,"x":376,"y":359,"deltaY":3},{"type":"mousewheel","time":85151,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":85200,"x":376,"y":359,"deltaY":12},{"type":"mousewheel","time":85235,"x":376,"y":359,"deltaY":11},{"type":"mousewheel","time":85267,"x":376,"y":359,"deltaY":10},{"type":"mousewheel","time":85314,"x":376,"y":359,"deltaY":13},{"type":"mousewheel","time":85349,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":85381,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":85426,"x":376,"y":359,"deltaY":11},{"type":"mousewheel","time":85461,"x":376,"y":359,"deltaY":6},{"type":"mousewheel","time":85721,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":85771,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":85804,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":85840,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":85892,"x":376,"y":359,"deltaY":13},{"type":"mousewheel","time":85927,"x":376,"y":359,"deltaY":12},{"type":"mousewheel","time":85960,"x":376,"y":359,"deltaY":6},{"type":"mousewheel","time":86013,"x":376,"y":359,"deltaY":3},{"type":"mousewheel","time":86244,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":86294,"x":376,"y":359,"deltaY":6},{"type":"mousewheel","time":86325,"x":376,"y":359,"deltaY":12},{"type":"mousewheel","time":86359,"x":376,"y":359,"deltaY":13},{"type":"mousewheel","time":86413,"x":376,"y":359,"deltaY":25},{"type":"mousewheel","time":86447,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":86483,"x":376,"y":359,"deltaY":11},{"type":"mousewheel","time":86534,"x":376,"y":359,"deltaY":71},{"type":"mousewheel","time":86568,"x":376,"y":359,"deltaY":23},{"type":"mousewheel","time":86604,"x":376,"y":359,"deltaY":19},{"type":"mousewheel","time":86676,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":86708,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":86741,"x":376,"y":359,"deltaY":15},{"type":"mousewheel","time":86789,"x":376,"y":359,"deltaY":22},{"type":"mousewheel","time":86823,"x":376,"y":359,"deltaY":36},{"type":"mousewheel","time":86859,"x":376,"y":359,"deltaY":14},{"type":"mousewheel","time":86904,"x":376,"y":359,"deltaY":17},{"type":"mousewheel","time":86935,"x":376,"y":359,"deltaY":95},{"type":"mousewheel","time":86964,"x":376,"y":359,"deltaY":40},{"type":"mousewheel","time":87014,"x":376,"y":359,"deltaY":34},{"type":"mousewheel","time":87044,"x":376,"y":359,"deltaY":41},{"type":"mousewheel","time":87078,"x":376,"y":359,"deltaY":22},{"type":"mousewheel","time":87158,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":87195,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":87256,"x":376,"y":359,"deltaY":14},{"type":"mousewheel","time":87292,"x":376,"y":359,"deltaY":28},{"type":"mousewheel","time":87359,"x":376,"y":359,"deltaY":25},{"type":"mousewheel","time":87393,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":87429,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":87493,"x":376,"y":359,"deltaY":19},{"type":"mousewheel","time":87528,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":87564,"x":376,"y":359,"deltaY":6},{"type":"mousewheel","time":87622,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":87657,"x":376,"y":359,"deltaY":4},{"type":"mousewheel","time":87755,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":87787,"x":376,"y":359,"deltaY":12},{"type":"mousewheel","time":87821,"x":376,"y":359,"deltaY":14},{"type":"mousewheel","time":87877,"x":376,"y":359,"deltaY":17},{"type":"mousewheel","time":87910,"x":376,"y":359,"deltaY":5},{"type":"mousewheel","time":87943,"x":376,"y":359,"deltaY":15},{"type":"mousewheel","time":87994,"x":376,"y":359,"deltaY":14},{"type":"mousewheel","time":88027,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":88062,"x":376,"y":359,"deltaY":8},{"type":"mousewheel","time":88133,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":88164,"x":376,"y":359,"deltaY":9},{"type":"mousewheel","time":88209,"x":376,"y":359,"deltaY":34},{"type":"mousewheel","time":88243,"x":376,"y":359,"deltaY":21},{"type":"mousewheel","time":88276,"x":376,"y":359,"deltaY":14},{"type":"mousewheel","time":88324,"x":376,"y":359,"deltaY":60},{"type":"mousewheel","time":88358,"x":376,"y":359,"deltaY":31},{"type":"mousewheel","time":88393,"x":376,"y":359,"deltaY":30},{"type":"mousewheel","time":88444,"x":376,"y":359,"deltaY":36},{"type":"mousewheel","time":88479,"x":376,"y":359,"deltaY":19},{"type":"mousewheel","time":88511,"x":376,"y":359,"deltaY":16},{"type":"mousewheel","time":88561,"x":376,"y":359,"deltaY":13},{"type":"mousewheel","time":88615,"x":376,"y":359,"deltaY":1},{"type":"mousewheel","time":88666,"x":376,"y":359,"deltaY":11},{"type":"mousewheel","time":88700,"x":376,"y":359,"deltaY":7},{"type":"mousewheel","time":88756,"x":376,"y":359,"deltaY":5},{"type":"mousewheel","time":88794,"x":376,"y":359,"deltaY":16},{"type":"mousewheel","time":88825,"x":376,"y":359,"deltaY":5},{"type":"mousewheel","time":88875,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":89054,"x":376,"y":359,"deltaY":2},{"type":"mousewheel","time":89106,"x":376,"y":359,"deltaY":10},{"type":"mousewheel","time":89138,"x":376,"y":359,"deltaY":19},{"type":"mousewheel","time":89175,"x":376,"y":359,"deltaY":5},{"type":"mousewheel","time":89224,"x":376,"y":359,"deltaY":1},{"type":"mousemove","time":89413,"x":376,"y":357},{"type":"mousemove","time":89623,"x":389,"y":259},{"type":"mousemove","time":89826,"x":389,"y":230},{"type":"mousedown","time":89955,"x":389,"y":230},{"type":"mouseup","time":90031,"x":389,"y":230},{"time":90032,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":90078,"x":389,"y":230},{"type":"mousedown","time":90481,"x":389,"y":230},{"type":"mouseup","time":90568,"x":389,"y":230},{"time":90569,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":90689,"x":389,"y":230},{"type":"mousemove","time":90895,"x":375,"y":352},{"type":"mousewheel","time":91162,"x":375,"y":352,"deltaY":-1},{"type":"mousewheel","time":91268,"x":375,"y":352,"deltaY":-82},{"type":"mousewheel","time":91326,"x":375,"y":352,"deltaY":-70},{"type":"mousewheel","time":91364,"x":375,"y":352,"deltaY":-57},{"type":"mousewheel","time":91403,"x":375,"y":352,"deltaY":-465},{"type":"mousewheel","time":91451,"x":375,"y":352,"deltaY":-165},{"type":"mousewheel","time":91494,"x":375,"y":352,"deltaY":-92},{"type":"mousewheel","time":91533,"x":375,"y":352,"deltaY":-113},{"type":"mousewheel","time":91611,"x":375,"y":352,"deltaY":-2},{"type":"mousewheel","time":91643,"x":375,"y":352,"deltaY":-40},{"type":"mousewheel","time":91693,"x":375,"y":352,"deltaY":-79},{"type":"mousewheel","time":91733,"x":375,"y":352,"deltaY":-65},{"type":"mousewheel","time":91769,"x":375,"y":352,"deltaY":-362},{"type":"mousewheel","time":91822,"x":375,"y":352,"deltaY":-198},{"type":"mousewheel","time":91858,"x":375,"y":352,"deltaY":-111},{"type":"mousewheel","time":91896,"x":375,"y":352,"deltaY":-98},{"type":"mousewheel","time":91955,"x":375,"y":352,"deltaY":-153},{"type":"mousewheel","time":91991,"x":375,"y":352,"deltaY":-31},{"type":"mousewheel","time":92072,"x":375,"y":352,"deltaY":-1},{"type":"mousewheel","time":92112,"x":375,"y":352,"deltaY":-109},{"type":"mousewheel","time":92146,"x":375,"y":352,"deltaY":-27},{"type":"mousewheel","time":92214,"x":375,"y":352,"deltaY":-45},{"type":"mousewheel","time":92251,"x":375,"y":352,"deltaY":-364},{"type":"mousewheel","time":92301,"x":375,"y":352,"deltaY":-130},{"type":"mousewheel","time":92342,"x":375,"y":352,"deltaY":-71},{"type":"mousewheel","time":92378,"x":375,"y":352,"deltaY":-59},{"type":"mousewheel","time":92430,"x":375,"y":352,"deltaY":-92},{"type":"mousewheel","time":92468,"x":375,"y":352,"deltaY":-36},{"type":"mousewheel","time":92502,"x":375,"y":352,"deltaY":-30},{"type":"mousewheel","time":92553,"x":375,"y":352,"deltaY":-13},{"type":"mousemove","time":92569,"x":373,"y":373},{"type":"mousemove","time":92792,"x":362,"y":534},{"type":"mousemove","time":92993,"x":373,"y":549},{"type":"mousemove","time":93201,"x":380,"y":558},{"type":"mousemove","time":93402,"x":379,"y":564},{"type":"mousemove","time":93603,"x":375,"y":568},{"type":"mousedown","time":93861,"x":375,"y":568},{"type":"mousemove","time":93875,"x":371,"y":568},{"type":"mousemove","time":94094,"x":169,"y":559},{"type":"mousemove","time":94313,"x":125,"y":561},{"type":"mousemove","time":94531,"x":61,"y":560},{"type":"mouseup","time":94823,"x":61,"y":560},{"time":94824,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":94844,"x":84,"y":559},{"type":"mousemove","time":95066,"x":271,"y":546},{"type":"mousemove","time":95280,"x":343,"y":552},{"type":"mousemove","time":95494,"x":382,"y":566},{"type":"mousemove","time":95718,"x":382,"y":566},{"type":"mousemove","time":95956,"x":381,"y":568},{"type":"mousemove","time":96215,"x":381,"y":568},{"type":"mousedown","time":96261,"x":381,"y":568},{"type":"mousemove","time":96277,"x":384,"y":568},{"type":"mousemove","time":96503,"x":639,"y":544},{"type":"mousemove","time":96743,"x":679,"y":537},{"type":"mousemove","time":96965,"x":781,"y":538},{"type":"mousemove","time":97193,"x":790,"y":538},{"type":"mouseup","time":97504,"x":790,"y":538},{"time":97505,"delay":400,"type":"screenshot-auto"},{"type":"mousemove","time":97526,"x":738,"y":525},{"type":"mousemove","time":97806,"x":612,"y":461},{"type":"mousemove","time":98080,"x":612,"y":460},{"type":"mousemove","time":98346,"x":659,"y":401},{"type":"mousemove","time":98627,"x":755,"y":367},{"type":"mousedown","time":98767,"x":746,"y":367},{"type":"mousemove","time":98849,"x":746,"y":368},{"type":"mouseup","time":98888,"x":746,"y":368},{"time":98889,"delay":400,"type":"screenshot-auto"}],"scrollY":900,"scrollX":0,"timestamp":1772553760335}] \ No newline at end of file From 18bedbb5a8f1f71784cb0b31e1eaed27e0595204 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 6 Mar 2026 21:16:17 +0800 Subject: [PATCH 22/31] fix: Temporarily fix incorrect stack base dimension selection when both axes are value axes. --- src/chart/helper/createSeriesData.ts | 4 +- src/coord/CoordinateSystem.ts | 3 +- src/coord/cartesian/Cartesian2D.ts | 10 ++- src/data/helper/dataStackHelper.ts | 23 +++++- src/model/referHelper.ts | 8 +- test/bar-polar-multi-series-radial.html | 6 +- test/bar-stack.html | 98 +++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 15 deletions(-) diff --git a/src/chart/helper/createSeriesData.ts b/src/chart/helper/createSeriesData.ts index a154906435..7ee0f55399 100644 --- a/src/chart/helper/createSeriesData.ts +++ b/src/chart/helper/createSeriesData.ts @@ -23,7 +23,7 @@ import prepareSeriesDataSchema from '../../data/helper/createDimensions'; import {getDimensionTypeByAxis} from '../../data/helper/dimensionHelper'; import {getDataItemValue} from '../../util/model'; import CoordinateSystem from '../../core/CoordinateSystem'; -import {getCoordSysInfoBySeries} from '../../model/referHelper'; +import {getCoordSysInfoBySeries, SeriesModelCoordSysInfo} from '../../model/referHelper'; import { createSourceFromSeriesDataOption, Source } from '../../data/Source'; import {enableDataStack} from '../../data/helper/dataStackHelper'; import {makeSeriesEncodeForAxisCoordSys} from '../../data/helper/sourceHelper'; @@ -40,7 +40,7 @@ import SeriesDimensionDefine from '../../data/SeriesDimensionDefine'; function getCoordSysDimDefs( seriesModel: SeriesModel, - coordSysInfo: ReturnType + coordSysInfo: SeriesModelCoordSysInfo ) { const coordSysName = seriesModel.get('coordinateSystem'); const registeredCoordSys = CoordinateSystem.get(coordSysName); diff --git a/src/coord/CoordinateSystem.ts b/src/coord/CoordinateSystem.ts index c5e8253081..9a444e60c3 100644 --- a/src/coord/CoordinateSystem.ts +++ b/src/coord/CoordinateSystem.ts @@ -190,8 +190,7 @@ export interface CoordinateSystem { getAxis?: (dim?: DimensionName) => Axis; /** - * FIXME: It may introduce inconsistency with `Series['getBaseAxis']`. - * `CoordinateSystem['getBaseAxis']` probably should not exist. + * FIXME: Remove this method? See details in `Cartesian2D['getBaseAxis']` */ getBaseAxis?: () => Axis; diff --git a/src/coord/cartesian/Cartesian2D.ts b/src/coord/cartesian/Cartesian2D.ts index 071f83b4d2..2636e40e23 100644 --- a/src/coord/cartesian/Cartesian2D.ts +++ b/src/coord/cartesian/Cartesian2D.ts @@ -91,8 +91,14 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { * Base axis will be used on stacking and series such as 'bar', 'pictorialBar', etc. */ getBaseAxis(): Axis2D { - // PENGING: Should we allow bar series to specify a base axis when - // both axes are type "value", rather than force to xAxis? + // FIXME: + // (1) We should allow series (e.g., bar) to specify a base axis when + // both axes are type "value", rather than force to xAxis or angleAxis. + // NOTE: At present BoxplotSeries has its own overide `getBaseAxis`. + // `CoordinateSystem['getBaseAxis']` probably should not exist, since it + // may introduce inconsistency with `Series['getBaseAxis']`. + // (2) "base axis" info is required in "createSeriesData" stage for "stack", + // (see `dataStackHelper.ts` for details). Currently it is hard coded there. return this.getAxesByScale('ordinal')[0] || this.getAxesByScale('time')[0] || this.getAxis('x'); diff --git a/src/data/helper/dataStackHelper.ts b/src/data/helper/dataStackHelper.ts index f10f7af1d7..206d47fc4b 100644 --- a/src/data/helper/dataStackHelper.ts +++ b/src/data/helper/dataStackHelper.ts @@ -93,6 +93,11 @@ export function enableDataStack( let stackedDimInfo: SeriesDimensionDefine; let stackResultDimension: string; let stackedOverDimension: string; + let allDimTypesAreNotOrdinalAndTime = true; + + function dimTypeIsNotOrdinalAndTime(dimensionInfo: SeriesDimensionDefine): boolean { + return dimensionInfo.type !== 'ordinal' && dimensionInfo.type !== 'time'; + } each(dimensionDefineList, function (dimensionInfo, index) { if (isString(dimensionInfo)) { @@ -100,7 +105,12 @@ export function enableDataStack( name: dimensionInfo as string } as SeriesDimensionDefine; } + if (!dimTypeIsNotOrdinalAndTime(dimensionInfo)) { + allDimTypesAreNotOrdinalAndTime = false; + } + }); + each(dimensionDefineList, function (dimensionInfo: SeriesDimensionDefine, index) { if (mayStack && !dimensionInfo.isExtraCoord) { // Find the first ordinal dimension as the stackedByDimInfo. if (!byIndex && !stackedByDimInfo && dimensionInfo.ordinalMeta) { @@ -108,8 +118,17 @@ export function enableDataStack( } // Find the first stackable dimension as the stackedDimInfo. if (!stackedDimInfo - && dimensionInfo.type !== 'ordinal' - && dimensionInfo.type !== 'time' + && dimTypeIsNotOrdinalAndTime(dimensionInfo) + // FIXME: + // This rule MUST be consistent with `Cartesian2D['getBaseAxis']` and `Polar['getBaseAxis']` + // Need refactor - merge them! + // See comments in `Cartesian2D['getBaseAxis']` for details. + && (!allDimTypesAreNotOrdinalAndTime + || ( + dimensionInfo.coordDim !== 'x' + && dimensionInfo.coordDim !== 'angle' + ) + ) && (!stackedCoordDimension || stackedCoordDimension === dimensionInfo.coordDim) ) { stackedDimInfo = dimensionInfo; diff --git a/src/model/referHelper.ts b/src/model/referHelper.ts index e9010a9835..c612c5eb64 100644 --- a/src/model/referHelper.ts +++ b/src/model/referHelper.ts @@ -61,7 +61,7 @@ import { AxisModelExtendedInCreator } from '../coord/axisModelCreator'; * } */ -class CoordSysInfo { +export class SeriesModelCoordSysInfo { coordSysName: string; @@ -84,14 +84,14 @@ type FetcherAxisModel = & Pick; type Fetcher = ( seriesModel: SeriesModel, - result: CoordSysInfo, + result: SeriesModelCoordSysInfo, axisMap: HashMap, categoryAxisMap: HashMap ) => void; -export function getCoordSysInfoBySeries(seriesModel: SeriesModel) { +export function getCoordSysInfoBySeries(seriesModel: SeriesModel): SeriesModelCoordSysInfo { const coordSysName = seriesModel.get('coordinateSystem') as SupportedCoordSys; - const result = new CoordSysInfo(coordSysName); + const result = new SeriesModelCoordSysInfo(coordSysName); const fetch = fetchers[coordSysName]; if (fetch) { fetch(seriesModel, result, result.axisMap, result.categoryAxisMap); diff --git a/test/bar-polar-multi-series-radial.html b/test/bar-polar-multi-series-radial.html index ec2bbb00d3..5185b25517 100644 --- a/test/bar-polar-multi-series-radial.html +++ b/test/bar-polar-multi-series-radial.html @@ -34,7 +34,7 @@ -
+
@@ -86,7 +86,7 @@ height: 500, }); - chart.on('click', function (params) { + chart && chart.on('click', function (params) { console.log(params); }); }); @@ -213,7 +213,7 @@ }] }); - chart.on('click', function (params) { + chart && chart.on('click', function (params) { console.log(params); }); }); diff --git a/test/bar-stack.html b/test/bar-stack.html index 51ce177f2f..09d2780985 100644 --- a/test/bar-stack.html +++ b/test/bar-stack.html @@ -39,6 +39,7 @@
+
@@ -725,6 +726,103 @@ + + + + + \ No newline at end of file From bb56e9ab152e26a1acae0863f2a4a460e844bd87 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 6 Mar 2026 21:18:59 +0800 Subject: [PATCH 23/31] chore: Sync the modification of #21448 to release. --- NOTICE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NOTICE b/NOTICE index 806bbd8b7c..c6a6e5e43b 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ Apache ECharts -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (https://www.apache.org/). \ No newline at end of file From bd19110f247d9221849f43d18f8b49b590095520 Mon Sep 17 00:00:00 2001 From: 100pah Date: Sun, 8 Mar 2026 02:51:11 +0800 Subject: [PATCH 24/31] fix: (1) Clarify and uniform terminology and add comments to explain EC_CYCLE. (2) Fix dataZoom in `appendData`, introduced by recent commits. --- src/component/dataZoom/dataZoomProcessor.ts | 2 + src/component/dataZoom/helper.ts | 3 +- .../thumbnail/ThumbnailBridgeImpl.ts | 2 +- src/component/thumbnail/ThumbnailView.ts | 2 +- src/coord/axisAlignTicks.ts | 4 + src/coord/axisStatistics.ts | 19 +-- src/coord/scaleRawExtentInfo.ts | 12 +- src/core/ExtensionAPI.ts | 2 +- src/core/Scheduler.ts | 23 ++- src/core/echarts.ts | 150 ++++++++++++------ src/core/task.ts | 10 +- src/label/LabelManager.ts | 2 +- src/label/labelLayoutHelper.ts | 2 +- src/model/OptionManager.ts | 6 - src/scale/scaleMapper.ts | 4 +- src/util/cycleCache.ts | 73 +++++++++ src/util/model.ts | 50 ------ src/util/types.ts | 57 +++++-- test/bar-polar-multi-series-radial.html | 2 +- 19 files changed, 269 insertions(+), 156 deletions(-) create mode 100644 src/util/cycleCache.ts diff --git a/src/component/dataZoom/dataZoomProcessor.ts b/src/component/dataZoom/dataZoomProcessor.ts index 96033e38dd..811b6a264d 100644 --- a/src/component/dataZoom/dataZoomProcessor.ts +++ b/src/component/dataZoom/dataZoomProcessor.ts @@ -30,6 +30,8 @@ import { AxisBaseModel } from '../../coord/AxisBaseModel'; const dataZoomProcessor: StageHandler = { + dirtyOnOverallProgress: true, + // `dataZoomProcessor` will only be performed in needed series. Consider if // there is a line series and a pie series, it is better not to update the // line series if only pie series is needed to be updated. diff --git a/src/component/dataZoom/helper.ts b/src/component/dataZoom/helper.ts index d3170ea2bf..9fb7c3b580 100644 --- a/src/component/dataZoom/helper.ts +++ b/src/component/dataZoom/helper.ts @@ -25,8 +25,9 @@ import SeriesModel from '../../model/Series'; import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import type AxisProxy from './AxisProxy'; -import { getCachePerECPrepare, GlobalModelCachePerECPrepare, makeInner } from '../../util/model'; +import { makeInner } from '../../util/model'; import type ComponentModel from '../../model/Component'; +import { getCachePerECPrepare, GlobalModelCachePerECPrepare } from '../../util/cycleCache'; export interface DataZoomPayloadBatchItem { diff --git a/src/component/thumbnail/ThumbnailBridgeImpl.ts b/src/component/thumbnail/ThumbnailBridgeImpl.ts index 9b33cec8c5..f155613dcd 100644 --- a/src/component/thumbnail/ThumbnailBridgeImpl.ts +++ b/src/component/thumbnail/ThumbnailBridgeImpl.ts @@ -51,7 +51,7 @@ export class ThumbnailBridgeImpl implements ThumbnailBridge { } reset(api: ExtensionAPI) { - this._renderVersion = api.getMainProcessVersion(); + this._renderVersion = api.getECMainCycleVersion(); } renderContent(opt: { diff --git a/src/component/thumbnail/ThumbnailView.ts b/src/component/thumbnail/ThumbnailView.ts index 617f6dfda1..a08f1470b2 100644 --- a/src/component/thumbnail/ThumbnailView.ts +++ b/src/component/thumbnail/ThumbnailView.ts @@ -70,7 +70,7 @@ export class ThumbnailView extends ComponentView { return; } - this._renderVersion = api.getMainProcessVersion(); + this._renderVersion = api.getECMainCycleVersion(); const group = this.group; group.removeAll(); diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 0fb54eedd4..61f11ed20f 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -56,6 +56,10 @@ export function scaleCalcAlign( targetScale, targetAxisModel, targetAxisModel.ecModel, targetAxis, null ); + // FIXME: + // (1) Axis inverse is not considered yet. + // (2) `SCALE_EXTENT_KIND_MAPPING` is not considered yet. + const isTargetLogScale = isLogScale(targetScale); const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.intervalStub : alignToScale; const targetIntervalStub = isTargetLogScale ? targetScale.intervalStub : targetScale; diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts index f64ae1faa4..ca24546195 100644 --- a/src/coord/axisStatistics.ts +++ b/src/coord/axisStatistics.ts @@ -21,8 +21,6 @@ import { assert, createHashMap, each, HashMap } from 'zrender/src/core/util'; import type GlobalModel from '../model/Global'; import type SeriesModel from '../model/Series'; import { - getCachePerECFullUpdate, getCachePerECPrepare, GlobalModelCachePerECFullUpdate, - GlobalModelCachePerECPrepare, initExtentForUnion, makeCallOnlyOnce, makeInner, } from '../util/model'; import { DimensionIndex, NullUndefined } from '../util/types'; @@ -34,6 +32,10 @@ import type { AxisBaseModel } from './AxisBaseModel'; import { tryEnsureTypedArray, Float64ArrayCtor } from '../util/vendor'; import { EChartsExtensionInstallRegisters } from '../extension'; import type ComponentModel from '../model/Component'; +import { + getCachePerECFullUpdate, getCachePerECPrepare, GlobalModelCachePerECFullUpdate, + GlobalModelCachePerECPrepare +} from '../util/cycleCache'; const callOnlyOnce = makeCallOnlyOnce(); @@ -237,7 +239,7 @@ export function eachAxisStatKey( }); } -function performAxisStatistics(ecModel: GlobalModel): void { +function performAxisStatisticsOnOverallReset(ecModel: GlobalModel): void { const ecFullUpdateCache = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)); const axisStatAll: AxisStatAll = ecFullUpdateCache.all = createHashMap(); @@ -350,11 +352,10 @@ function performStatisticsForRecord( } if (!ecPrepareCacheMiss && ecPrepareLiPosMinGap != null) { // Consider the fact in practice: - // - Series data can only be changed in the "ec prepare" stage. - // - The relationship between series and axes can only be changed in "ec prepare" stage and - // `SERIES_FILTER`. - // (NOTE: "ec prepare" stage can be typically considered as `chart.setOption`, and "ec updated" - // stage can be typically considered as `dispatchAction`.) + // - Series data can only be changed in EC_PREPARE_UPDATE. + // - The relationship between series and axes can only be changed in EC_PREPARE_UPDATE and + // SERIES_FILTER. + // (See EC_CYCLE for more info) // Therefore, some statistics results can be cached in `GlobalModelCachePerECPrepare` to avoid // repeated time-consuming calculation for large data (e.g., over 1e5 data items). perKeyPerAxis.liPosMinGap = ecPrepareLiPosMinGap; @@ -452,7 +453,7 @@ export function requireAxisStatistics( callOnlyOnce(registers, function () { registers.registerProcessor(registers.PRIORITY.PROCESSOR.AXIS_STATISTICS, { - overallReset: performAxisStatistics + overallReset: performAxisStatisticsOnOverallReset }); }); } diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index d9d7ed6b41..f378b5da55 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -58,7 +58,7 @@ import { AxisStatKey, eachAxisStatKey } from './axisStatistics'; */ const scaleInner = makeInner<{ extent: number[]; - // series on this axis. + // series on this axis to union data extent. seriesList: SeriesModel[]; dimIdxInCoord: number; }, Scale>(); @@ -422,9 +422,6 @@ export class ScaleRawExtentInfo { * The outcome `_zoomMM` may have both `NullUndefined` and a finite value, like `[undefined, 123]`. */ setZoomMinMax(idxMinMax: 0 | 1, val: number | NullUndefined): void { - if (__DEV__) { - assert(this._i.zoomMM[idxMinMax] == null); - } this._i.zoomMM[idxMinMax] = val; } @@ -596,8 +593,11 @@ export function scaleRawExtentInfoReallyCreate( if (scale.rawExtentInfo) { if (__DEV__) { // Check for incorrect impl - the duplicated calling of this method is only allowed in - // one case: first dataZoom then coord sys update. - assert(scale.rawExtentInfo.from !== from); + // these cases: + // - First in `AxisProxy['reset']` (for dataZoom) + // - Then in `CoordinateSystem['update']`. + // - Then after `chart.appendData()` due to `dirtyOnOverallProgress: true` + assert(scale.rawExtentInfo.from !== from || from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); } return; } diff --git a/src/core/ExtensionAPI.ts b/src/core/ExtensionAPI.ts index 047bb704c9..cfba0a270b 100644 --- a/src/core/ExtensionAPI.ts +++ b/src/core/ExtensionAPI.ts @@ -72,7 +72,7 @@ abstract class ExtensionAPI { abstract getViewOfComponentModel(componentModel: ComponentModel): ComponentView; abstract getViewOfSeriesModel(seriesModel: SeriesModel): ChartView; abstract getModel(): GlobalModel; - abstract getMainProcessVersion(): number; + abstract getECMainCycleVersion(): number; } export default ExtensionAPI; diff --git a/src/core/Scheduler.ts b/src/core/Scheduler.ts index 745631111d..c87690e958 100644 --- a/src/core/Scheduler.ts +++ b/src/core/Scheduler.ts @@ -68,6 +68,8 @@ type TaskRecord = { overallTask?: OverallTask }; type PerformStageTaskOpt = { + // `block` means running from the beginning to the final end within + // an individual "progress". block?: boolean, setDirty?: boolean, visualType?: StageHandlerInternal['visualType'], @@ -96,7 +98,7 @@ interface OverallTaskContext extends TaskContext { } interface StubTaskContext extends TaskContext { model: SeriesModel; - overallProgress: boolean; + dirtyOnOverallProgress: boolean; }; class Scheduler { @@ -147,7 +149,7 @@ class Scheduler { // if a data processor depends on a component (e.g., dataZoomProcessor depends // on the settings of `dataZoom`), it should be re-performed if the component // is modified by `setOption`. - // (2) If a processor depends on sevral series, speicified by its `getTargetSeries`, + // (2) If a processor depends on several series, specified by its `getTargetSeries`, // it should be re-performed when the result array of `getTargetSeries` changed. // We use `dependencies` to cover these issues. // (3) How to update target series when coordinate system related components modified. @@ -488,15 +490,13 @@ class Scheduler { const seriesType = stageHandler.seriesType; const getTargetSeries = stageHandler.getTargetSeries; - let overallProgress = true; + const dirtyOnOverallProgress = stageHandler.dirtyOnOverallProgress; let shouldOverallTaskDirty = false; // FIXME:TS never used, so comment it // let modifyOutputEnd = stageHandler.modifyOutputEnd; // An overall task with seriesType detected or has `getTargetSeries`, we add - // stub in each pipelines, it will set the overall task dirty when the pipeline - // progress. Moreover, to avoid call the overall task each frame (too frequent), - // we set the pipeline block. + // stub in each pipelines to receive dirty info from upstream. let errMsg = ''; if (__DEV__) { errMsg = '"createOnAllSeries" is not supported for "overallReset", ' @@ -509,12 +509,7 @@ class Scheduler { else if (getTargetSeries) { getTargetSeries(ecModel, api).each(createStub); } - // Otherwise, (usually it is legacy case), the overall task will only be - // executed when upstream is dirty. Otherwise the progressive rendering of all - // pipelines will be disabled unexpectedly. But it still needs stubs to receive - // dirty info from upstream. else { - overallProgress = false; each(ecModel.getSeries(), createStub); } @@ -534,12 +529,12 @@ class Scheduler { ); stub.context = { model: seriesModel, - overallProgress: overallProgress + dirtyOnOverallProgress: dirtyOnOverallProgress // FIXME:TS never used, so comment it // modifyOutputEnd: modifyOutputEnd }; stub.agent = overallTask; - stub.__block = overallProgress; + stub.__block = dirtyOnOverallProgress; scheduler._pipe(seriesModel, stub); } @@ -586,7 +581,7 @@ function overallTaskReset(context: OverallTaskContext): void { } function stubReset(context: StubTaskContext): TaskProgressCallback { - return context.overallProgress && stubProgress; + return context.dirtyOnOverallProgress && stubProgress; } function stubProgress(this: StubTask): void { diff --git a/src/core/echarts.ts b/src/core/echarts.ts index dc350f92cb..ad5bb2bdc5 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -139,6 +139,7 @@ import type geoSourceManager from '../coord/geo/geoSourceManager'; import { registerCustomSeries as registerCustom } from '../chart/custom/customSeriesRegister'; +import { resetCachePerECFullUpdate, resetCachePerECPrepare } from '../util/cycleCache'; declare let global: any; @@ -203,17 +204,68 @@ export const PRIORITY = { } }; -// Main process have three entries: `setOption`, `dispatchAction` and `resize`, -// where they must not be invoked nestedly, except the only case: invoke -// dispatchAction with updateMethod "none" in main process. -// This flag is used to carry out this rule. -// All events will be triggered out side main process (i.e. when !this[IN_MAIN_PROCESS]). -const IN_MAIN_PROCESS_KEY = '__flagInMainProcess' as const; +/** + * [ec updating/rendering cycles (EC_CYCLE)] + * + * - EC_MAIN_CYCLE: + * - It designates a run of a series of processing/updating/rendering. + * - It is triggered by: + * - `setOption` + * - `dispatchAction` + * (It is typically internally triggered by user inputs; but can also an explicit API call.) + * - `resize` + * - The next "animation frame" if in `lazyMode: true`. + * - Nested entry is not allowed. If triggering a new run of EC_MAIN_CYCLE during a + * unfinished run, the new run will be delayed until the current run finishes + * (if triggered by `dispatchAction`), or throw error (if triggered by other API calls). + * - All user-visible ec events are triggered outside EC_MAIN_CYCLE + * (i.e. be triggered after `this[IN_EC_MAIN_CYCLE_KEY]` becoming `false`). + * - A run of EC_MAIN_CYCLE comprises: + * - EC_PREPARE_UPDATE (may be absent) + * - EC_FULL_UPDATE or EC_PARTIAL_UPDATE + * - A run of EC_FULL_UPDATE comprises: + * - CoordinateSystem['create'] + * - Data processing (may be absent) (see `registerProcessor`) + * - CoordinateSystem['update'] (may be absent) + * - Visual encoding (may be absent) (see `registerVisual`) + * - Layout (may be absent) (see `registerLayout`) + * - Rendering (`ComponentView` or `SeriesView`) + * + * - EC_PROGRESSIVE_CYCLE: + * - It also carries out a series of processing/updating/rendering, but out of EC_MAIN_CYCLE. + * - It is performed in each "animation frame". + * - It can be triggered internally or `appendData` call. + * - A run of EC_PROGRESSIVE_CYCLE comprises: + * - Data processing (may be absent) (see `registerProcessor`) + * - Visual encoding (may be absent) (see `registerVisual`) + * - Layout (may be absent) (see `registerLayout`) + * - Rendering (`ComponentView` or `SeriesView`) + * - PENDING: currently all data processing tasks (via `registerProcessor`) run in "block" mode. + * (see `performDataProcessorTasks`) + * + * - Other updating/rendering cycles: + * - Some series have specific update/render cycles. For example, graph force layout performs + * layout and rendering in each "animation frame". + * + * - Model updating: + * - Model can only be modified at the beginning of ec cycles, including only: + * - EC_PREPARE_UPDATE (see method `prepare()`) in `setOption` call. + * - EC action handlers in `dispatchAction` call. + * - `appendData` (a special case, where only data is modified). + * + * - The lifetime of CoordinateSystem/Axis/Scale instances: + * - They are only re-created per run of EC_FULL_UPDATE. + * + * - Available caches: see `cycleCache.ts` + */ + +// See comments in EC_CYCLE. +const IN_EC_MAIN_CYCLE_KEY = '__flagInMainProcess' as const; // Useful for detecting outdated rendering results in scenarios that these issues are involved: -// - Use shortcut (such as, updateTransform, or no update) to start a main process. +// - Use EC_PARTIAL_UPDATE (such as, updateTransform, or no update) to start an EC_MAIN_CYCLE. // - Asynchronously update rendered view (e.g., graph force layout). // - Multiple ChartView/ComponentView render to one group cooperatively. -const MAIN_PROCESS_VERSION_KEY = '__mainProcessVersion' as const; +const EC_MAIN_CYCLE_VERSION_KEY = '__mainProcessVersion' as const; const PENDING_UPDATE = '__pendingUpdate' as const; const STATUS_NEEDS_UPDATE_KEY = '__needsUpdateStatus' as const; const ACTION_REG = /^[a-zA-Z0-9_]+$/; @@ -285,7 +337,7 @@ messageCenterProto.off = createRegisterEventWithLowercaseMessageCenter('off'); // --------------------------------------- // Internal method names for class ECharts // --------------------------------------- -let prepare: (ecIns: ECharts) => void; +let prepare: (ecIns: ECharts) => void; // This is `EC_PREPARE_UPDATE`. let prepareView: (ecIns: ECharts, isComponent: boolean) => void; let updateDirectly: ( ecIns: ECharts, method: string, payload: Payload, mainType: ComponentMainType, subType?: ComponentSubType @@ -293,11 +345,11 @@ let updateDirectly: ( type UpdateMethod = (this: ECharts, payload?: Payload, renderParams?: UpdateLifecycleParams) => void; let updateMethods: { prepareAndUpdate: UpdateMethod, - update: UpdateMethod, - updateTransform: UpdateMethod, - updateView: UpdateMethod, - updateVisual: UpdateMethod, - updateLayout: UpdateMethod + update: UpdateMethod, // This is `EC_FULL_UPDATE`. + updateTransform: UpdateMethod, // This is one of `EC_PARTIAL_UPDATE`. + updateView: UpdateMethod, // This is one of `EC_PARTIAL_UPDATE`. + updateVisual: UpdateMethod, // This is one of `EC_PARTIAL_UPDATE`. + updateLayout: UpdateMethod // This is one of `EC_PARTIAL_UPDATE`. }; let doConvertPixel: { ( @@ -345,7 +397,7 @@ let enableConnect: (ecIns: ECharts) => void; let markStatusToUpdate: (ecIns: ECharts) => void; let applyChangedStates: (ecIns: ECharts) => void; -let updateMainProcessVersion: (ecIns: ECharts) => void; +let updateECMainCycleVersion: (ecIns: ECharts) => void; type RenderedEventParam = { elapsedTime: number }; type ECEventDefinition = { @@ -427,8 +479,8 @@ class ECharts extends Eventful { silent: boolean updateParams: UpdateLifecycleParams }; - private [IN_MAIN_PROCESS_KEY]: boolean; - private [MAIN_PROCESS_VERSION_KEY]: number; + private [IN_EC_MAIN_CYCLE_KEY]: boolean; + private [EC_MAIN_CYCLE_VERSION_KEY]: number; private [CONNECT_STATUS_KEY]: ConnectStatus; private [STATUS_NEEDS_UPDATE_KEY]: boolean; @@ -451,7 +503,7 @@ class ECharts extends Eventful { let defaultCoarsePointer: 'auto' | boolean = 'auto'; let defaultUseDirtyRect = false; - this[MAIN_PROCESS_VERSION_KEY] = 1; + this[EC_MAIN_CYCLE_VERSION_KEY] = 1; if (__DEV__) { const root = ( @@ -545,15 +597,15 @@ class ECharts extends Eventful { if (this[PENDING_UPDATE]) { const silent = (this[PENDING_UPDATE] as any).silent; - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); try { prepare(this); updateMethods.update.call(this, null, this[PENDING_UPDATE].updateParams); } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; this[PENDING_UPDATE] = null; throw e; } @@ -566,7 +618,7 @@ class ECharts extends Eventful { // will render the final state of the elements before the real animation started. this._zr.flush(); - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; this[PENDING_UPDATE] = null; flushPendingActions.call(this, silent); @@ -649,7 +701,7 @@ class ECharts extends Eventful { setOption(option: Opt, opts?: SetOptionOpts): void; /* eslint-disable-next-line */ setOption(option: Opt, notMerge?: boolean | SetOptionOpts, lazyUpdate?: boolean): void { - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { if (__DEV__) { error('`setOption` should not be called during main process.'); } @@ -672,8 +724,8 @@ class ECharts extends Eventful { notMerge = notMerge.notMerge; } - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); if (!this._model || notMerge) { const optionManager = new OptionManager(this._api); @@ -696,7 +748,7 @@ class ECharts extends Eventful { silent: silent, updateParams: updateParams }; - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; // `setOption(option, {lazyMode: true})` may be called when zrender has been slept. // It should wake it up to make sure zrender start to render at the next frame. @@ -709,7 +761,7 @@ class ECharts extends Eventful { } catch (e) { this[PENDING_UPDATE] = null; - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } @@ -722,7 +774,7 @@ class ECharts extends Eventful { } this[PENDING_UPDATE] = null; - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; flushPendingActions.call(this, silent); triggerUpdatedEvent.call(this, silent); @@ -735,7 +787,7 @@ class ECharts extends Eventful { * @param opts Optional settings */ setTheme(theme: string | ThemeOption, opts?: SetThemeOpts): void { - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { if (__DEV__) { error('`setTheme` should not be called during main process.'); } @@ -763,8 +815,8 @@ class ECharts extends Eventful { this[PENDING_UPDATE] = null; } - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); try { this._updateTheme(theme); @@ -774,11 +826,11 @@ class ECharts extends Eventful { updateMethods.update.call(this, {type: 'setTheme'}, updateParams); } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; flushPendingActions.call(this, silent); triggerUpdatedEvent.call(this, silent); @@ -1357,7 +1409,7 @@ class ECharts extends Eventful { * Resize the chart */ resize(opts?: ResizeOpts): void { - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { if (__DEV__) { error('`resize` should not be called during main process.'); } @@ -1395,8 +1447,8 @@ class ECharts extends Eventful { this[PENDING_UPDATE] = null; } - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); try { needPrepare && prepare(this); @@ -1409,11 +1461,11 @@ class ECharts extends Eventful { }); } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; flushPendingActions.call(this, silent); @@ -1507,7 +1559,7 @@ class ECharts extends Eventful { } // May dispatchAction in rendering procedure - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { this._pendingActions.push(payload); return; } @@ -1579,7 +1631,7 @@ class ECharts extends Eventful { private static internalField = (function () { prepare = function (ecIns: ECharts): void { - modelUtil.resetCachePerECPrepare(ecIns._model); + resetCachePerECPrepare(ecIns._model); const scheduler = ecIns._scheduler; @@ -1806,7 +1858,7 @@ class ECharts extends Eventful { return; } - modelUtil.resetCachePerECFullUpdate(ecModel); + resetCachePerECFullUpdate(ecModel); ecModel.setUpdatePayload(payload); scheduler.restoreData(ecModel, payload); @@ -2050,8 +2102,8 @@ class ECharts extends Eventful { const updateMethod = cptTypeTmp.pop(); const cptType = cptTypeTmp[0] != null && parseClassType(cptTypeTmp[0]); - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); let payloads: Payload[] = [payload]; let batched = false; @@ -2122,7 +2174,7 @@ class ECharts extends Eventful { } } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } } @@ -2139,7 +2191,7 @@ class ECharts extends Eventful { eventObj = eventObjBatch[0] as ECActionEvent; } - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; if (!silent) { let refinedEvent: ECActionEvent; @@ -2434,8 +2486,8 @@ class ECharts extends Eventful { ecIns.getZr().wakeUp(); }; - updateMainProcessVersion = function (ecIns: ECharts): void { - ecIns[MAIN_PROCESS_VERSION_KEY] = (ecIns[MAIN_PROCESS_VERSION_KEY] + 1) % 1000; + updateECMainCycleVersion = function (ecIns: ECharts): void { + ecIns[EC_MAIN_CYCLE_VERSION_KEY] = (ecIns[EC_MAIN_CYCLE_VERSION_KEY] + 1) % 1000; }; applyChangedStates = function (ecIns: ECharts): void { @@ -2666,8 +2718,8 @@ class ECharts extends Eventful { getViewOfSeriesModel(seriesModel: SeriesModel): ChartView { return ecIns.getViewOfSeriesModel(seriesModel); } - getMainProcessVersion(): number { - return ecIns[MAIN_PROCESS_VERSION_KEY]; + getECMainCycleVersion(): number { + return ecIns[EC_MAIN_CYCLE_VERSION_KEY]; } })(ecIns); }; diff --git a/src/core/task.ts b/src/core/task.ts index 6a25c1c127..20a59d3fb6 100644 --- a/src/core/task.ts +++ b/src/core/task.ts @@ -112,7 +112,7 @@ export class Task { // Injected in schedular __pipeline: Pipeline; __idxInPipeline: number; - __block: boolean; + __block: boolean; // FIXME: simplify it - merge with PerformStageTaskOpt['block']? // Context must be specified implicitly, to // avoid miss update context when model changed. @@ -242,6 +242,14 @@ export class Task { return this.unfinished(); } + /** + * Generally, task dirty propagates to downstream tasks. + * Task dirty leads to the `reset` call, which discards the previous result and starts over + * the processing. + * + * See `StageHandler['reset']` and `StageHandler['overallReset']` for a summary of possible + * `dirty()` calls. + */ dirty(): void { this._dirty = true; this._onDirty && this._onDirty(this.context); diff --git a/src/label/LabelManager.ts b/src/label/LabelManager.ts index b60a9ac2b2..411af30812 100644 --- a/src/label/LabelManager.ts +++ b/src/label/LabelManager.ts @@ -86,7 +86,7 @@ interface LabelDesc { * Save the original value determined in a pass of echarts main process. That refers to the values * rendered by `SeriesView`, before `series:layoutlabels` is triggered in `renderSeries`. * - * 'series:layoutlabels' may be triggered during some shortcut passes, such as zooming in series.graph/geo + * 'series:layoutlabels' may be triggered during some EC_PARTIAL_UPDATE passes, such as zooming in series.graph/geo * (`updateLabelLayout`), where the modified `Element` props should be restorable by the original value here. * * Regarding `Element` state, simply consider the values here as the normal state values. diff --git a/src/label/labelLayoutHelper.ts b/src/label/labelLayoutHelper.ts index 1136bb63d8..8567811185 100644 --- a/src/label/labelLayoutHelper.ts +++ b/src/label/labelLayoutHelper.ts @@ -509,7 +509,7 @@ export function restoreIgnore(labelList: LabelLayoutData[]): void { /** * [NOTICE - restore]: - * 'series:layoutlabels' may be triggered during some shortcut passes, such as zooming in series.graph/geo + * 'series:layoutlabels' may be triggered during some EC_PARTIAL_UPDATE passes, such as zooming in series.graph/geo * (`updateLabelLayout`), where the modified `Element` props should be restorable from `defaultAttr`. * @see `SavedLabelAttr` in `LabelManager.ts` * `restoreIgnore` can be called to perform the restore, if needed. diff --git a/src/model/OptionManager.ts b/src/model/OptionManager.ts index ef795ba58e..846d3c2157 100644 --- a/src/model/OptionManager.ts +++ b/src/model/OptionManager.ts @@ -17,12 +17,6 @@ * under the License. */ -/** - * ECharts option manager - */ - - -// import ComponentModel, { ComponentModelConstructor } from './Component'; import ExtensionAPI from '../core/ExtensionAPI'; import { OptionPreprocessor, MediaQuery, ECUnitOption, MediaUnit, ECBasicOption, SeriesOption diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts index 76dd2e8719..8455944308 100644 --- a/src/scale/scaleMapper.ts +++ b/src/scale/scaleMapper.ts @@ -205,7 +205,7 @@ export interface ScaleMapperGeneric { /** * [NOTICE]: - * In ec workflow, scale extent is finally determined at `coordSys#update` stage. + * In EC_MAIN_CYCLE, scale extent is finally determined at `coordSys#update` stage. * * Get a clone of the scale extent. * An extent is always in an increase order. @@ -230,7 +230,7 @@ export interface ScaleMapperGeneric { * * `setExtent` is identical to `setExtent2(SCALE_EXTENT_KIND_EFFECTIVE)`. * - * [The steps of extent construction in ec workflow]: + * [The steps of extent construction in EC_MAIN_CYCLE]: * - step#1. At `CoordinateSystem#create` stage, requirements of collecting series data extents are * committed to `scaleRawExtentInfoRequireCreate`, and `Scale` instances are created. * - step#2. Call `scaleRawExtentInfoReallyCreate` to really collect series data extent and create diff --git a/src/util/cycleCache.ts b/src/util/cycleCache.ts new file mode 100644 index 0000000000..7d653bb72f --- /dev/null +++ b/src/util/cycleCache.ts @@ -0,0 +1,73 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import GlobalModel from '../model/Global'; +import { makeInner } from './model'; + + +const ecModelCacheInner = makeInner<{ + fullUpdate: GlobalModelCachePerECFullUpdate; + prepare: GlobalModelCachePerECPrepare; +}, GlobalModel>(); + +export type GlobalModelCachePerECPrepare = {__: 'prepare'}; // Nominal to distinguish. +export type GlobalModelCachePerECFullUpdate = {__: 'fullUpdate'}; // Nominal to distinguish. + +/** + * CAVEAT: Can only be called by `echarts.ts` + */ +export function resetCachePerECPrepare(ecModel: GlobalModel): void { + ecModelCacheInner(ecModel).prepare = {} as GlobalModelCachePerECPrepare; +} + +/** + * CAVEAT: Can only be called by `echarts.ts` + */ +export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { + ecModelCacheInner(ecModel).fullUpdate = {} as GlobalModelCachePerECFullUpdate; +} + +/** + * The cache is auto cleared at the beginning of EC_PREPARE_UPDATE. + * See also comments in EC_CYCLE. + * + * NOTICE: + * - EC_PREPARE_UPDATE is not necessarily executed before each EC_FULL_UPDATE performing. + * Typically, `setOption` trigger EC_PREPARE_UPDATE, but `dispatchAction` does not. + * - It is not cleared in EC_PARTIAL_UPDATE and EC_PROGRESSIVE_CYCLE. + * + */ +export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { + return ecModelCacheInner(ecModel).prepare; +} + +/** + * The cache is auto cleared at the beginning of EC_FULL_UPDATE. + * See also comments in EC_CYCLE. + * + * NOTICE: + * - It is not cleared in EC_PARTIAL_UPDATE and EC_PROGRESSIVE_CYCLE. + * - The cache should NOT be written before EC_FULL_UPDATE started, such as: + * - should NOT in `getTargetSeries` methods of data processors. + * - should NOT in `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. + * - See `getCachePerECPrepare` for details. + */ +export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { + return ecModelCacheInner(ecModel).fullUpdate; +} diff --git a/src/util/model.ts b/src/util/model.ts index 5412ee0584..95d7df3d6d 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -30,7 +30,6 @@ import { isStringSafe, isNumber, hasOwn, - isTypedArray, } from 'zrender/src/core/util'; import env from 'zrender/src/core/env'; import GlobalModel from '../model/Global'; @@ -1274,55 +1273,6 @@ export function makeCallOnlyOnce() { let onceUniqueIndex = getRandomIdBase(); -const ecModelCacheInner = makeInner<{ - fullUpdate: GlobalModelCachePerECFullUpdate; - prepare: GlobalModelCachePerECPrepare; -}, GlobalModel>(); - -export type GlobalModelCachePerECPrepare = {__: 'prepare'}; // Nominal to distinguish. -export type GlobalModelCachePerECFullUpdate = {__: 'fullUpdate'}; // Nominal to distinguish. - -/** - * CAVEAT: Can only be called by `echarts.ts` - */ -export function resetCachePerECPrepare(ecModel: GlobalModel): void { - ecModelCacheInner(ecModel).prepare = {} as GlobalModelCachePerECPrepare; -} - -/** - * CAVEAT: Can only be called by `echarts.ts` - */ -export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { - ecModelCacheInner(ecModel).fullUpdate = {} as GlobalModelCachePerECFullUpdate; -} - -/** - * The cache is auto cleared at the begining of a run of "ec prepare". - * Typically, `setOption` trigger "ec prepare", but `dispatchAction` does not. - * - * NOTICE: - * - "ec prepare" is not necessarily performed before each "ec full update" performing. - */ -export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { - return ecModelCacheInner(ecModel).prepare; -} - -/** - * The cache is auto cleared at the begining of a run of "ec full update". - * However, all shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. - * Typically, all `setOption` and some `dispatchAction` trigger "ec full update". - * This is the same as the lifecycle of coordinate systems instances and axes instances. - * - * NOTICE: - * - The cache should NOT be written in: - * - `getTargetSeries` methods of data processors. - * - `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. - * See `getCachePerECPrepare` for details. - */ -export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { - return ecModelCacheInner(ecModel).fullUpdate; -} - /** * @usage * - The earlier item takes precedence for duplicate items. diff --git a/src/util/types.ts b/src/util/types.ts index ad6e962ad0..aebc93ce6c 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -336,19 +336,25 @@ export interface StageHandlerOverallReset { (ecModel: GlobalModel, api: ExtensionAPI, payload?: Payload): void } export interface StageHandler { + /** - * Indicate that the task will be piped all series - * (`performRawSeries` indicate whether includes filtered series). + * Indicate that the "series stage task" will be piped for all series + * (filtered series is included iff `performRawSeries: true`). + * + * OVERALL_STAGE_TASK (See `overallReset`) can not set `createOnAllSeries: true`. */ createOnAllSeries?: boolean; + /** - * Indicate that the task will be only piped in the pipeline of this type of series. - * (`performRawSeries` indicate whether includes filtered series). + * Indicate that the task will only be piped in the pipeline of this type of series. + * (filtered series is included iff `performRawSeries: true`). + * It is available for both `reset` and `overallReset`. */ seriesType?: string; + /** - * Indicate that the task will be only piped in the pipeline of the returned series. - * Called in "prepare" stage, before coord sys creation. + * Indicate that the task will only be piped in the pipeline of the returned series. + * It is called in EC_PREPARE_UPDATE, before `CoordinateSystem['create']`. * It is available for both `reset` and `overallReset`. */ getTargetSeries?: (ecModel: GlobalModel, api: ExtensionAPI) => HashMap; @@ -362,18 +368,45 @@ export interface StageHandler { * Called only when this task in a pipeline. */ plan?: StageHandlerPlan; + /** - * If `overallReset` specified, an "overall task" will be created. - * "overall task" does not belong to a certain pipeline. - * They always be "performed" in certain phase (depends on when they declared). - * They has "stub"s to connect with pipelines (one stub for one pipeline), - * delivering info like "dirty" and "output end". + * If `overallReset` is specified, an OVERALL_STAGE_TASK will be created. + * An OVERALL_STAGE_TASK resides across multiple pipelines, and is associated with + * pipelines by "stub"s, which deliver messages like "dirty" and "output end". + * OVERALL_STAGE_TASK does not support `progess` method. + * + * The `overallReset` method is called iff this task is "dirty" (See `Task['dirty']`). + * See `StageHandler['reset']` for a summary of possible `dirty()` calls. */ overallReset?: StageHandlerOverallReset; + /** - * Called only when this task in a single pipeline, and "dirty". + * If `reset` is specified, a SERIES_STAGE_TASK will be created. + * A SERIES_STAGE_TASK is owned by a pipeline and is specific to a single series. + * + * The `reset` method is called iff this task is "dirty" (See `Task['dirty']`). + * Task `dirty()` call typically originates from: + * - A trigger of EC_MAIN_CYCLE (including EC_FULL_UPDATE and EC_PARTIAL_UPDATE) + * (See comments in EC_CYCLE) + * - NOTICE: `dirtyOnOverallProgress: true` cause that the corresponding `overallReset` + * and `reset` of downsteams tasks may also be called in EC_PROGRESSIVE_CYCLE. + * But in this case, `CoordinateSystem#create` and `CoordinateSystem#update` are + * not called. */ reset?: StageHandlerReset; + + /** + * This is a temporary mechanism for dataZoom case in `appendData`. + * + * It will set the OVERALL_STAGE_TASK dirty when the pipeline progress. + * Moreover, to avoid call the OVERALL_STAGE_TASK each frame (too frequent), + * it set the pipeline block (via `task.__block`) in this stage. + * + * Otherwise, (usually it is legacy case), the OVERALL_STAGE_TASK will only be + * executed when upstream is dirty. Otherwise the progressive rendering of all + * pipelines will be disabled unexpectedly. + */ + dirtyOnOverallProgress?: boolean; } export interface StageHandlerInternal extends StageHandler { diff --git a/test/bar-polar-multi-series-radial.html b/test/bar-polar-multi-series-radial.html index 5185b25517..73374c2a5d 100644 --- a/test/bar-polar-multi-series-radial.html +++ b/test/bar-polar-multi-series-radial.html @@ -34,7 +34,7 @@ - +
From eb7530b3ed0687dcdc32eb68a9f2751386eb8333 Mon Sep 17 00:00:00 2001 From: 100pah Date: Sun, 8 Mar 2026 17:34:52 +0800 Subject: [PATCH 25/31] fix(appendData): Fix that the dataZoom inside is disabled when appendData is executed. And clarify the usage of appendData in comments. --- src/component/dataZoom/roams.ts | 119 ++++++++++++++++---------------- src/core/echarts.ts | 31 +++++---- src/core/lifecycle.ts | 1 + src/util/model.ts | 7 +- src/util/types.ts | 15 ++-- test/candlestick-large3.html | 21 +++--- 6 files changed, 101 insertions(+), 93 deletions(-) diff --git a/src/component/dataZoom/roams.ts b/src/component/dataZoom/roams.ts index 38800b8c35..acd1b30fee 100644 --- a/src/component/dataZoom/roams.ts +++ b/src/component/dataZoom/roams.ts @@ -18,7 +18,7 @@ */ // Only create one roam controller for each coordinate system. -// one roam controller might be refered by two inside data zoom +// one roam controller might be referred by two inside data zoom // components (for example, one for x and one for y). When user // pan or zoom, only dispatch one action for those data zoom // components. @@ -39,7 +39,6 @@ import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { DataZoomGetRangeHandlers } from './InsideZoomView'; import { EChartsExtensionInstallRegisters } from '../../extension'; - interface DataZoomInfo { getRange: DataZoomGetRangeHandlers; model: InsideZoomModel; @@ -240,71 +239,69 @@ function mergeControllerParams( export function installDataZoomRoamProcessor(registers: EChartsExtensionInstallRegisters) { - registers.registerProcessor( - registers.PRIORITY.PROCESSOR.FILTER, - function (ecModel: GlobalModel, api: ExtensionAPI): void { - const apiInner = inner(api); - const coordSysRecordMap = apiInner.coordSysRecordMap - || (apiInner.coordSysRecordMap = createHashMap()); - - coordSysRecordMap.each(function (coordSysRecord) { - // `coordSysRecordMap` always exists (because it holds the `roam controller`, which should - // better not re-create each time), but clear `dataZoomInfoMap` each round of the workflow. - coordSysRecord.dataZoomInfoMap = null; - }); + registers.registerUpdateLifecycle('coordsys:aftercreate', (ecModel, api) => { + const apiInner = inner(api); + const coordSysRecordMap = apiInner.coordSysRecordMap + || (apiInner.coordSysRecordMap = createHashMap()); - ecModel.eachComponent( - { mainType: 'dataZoom', subType: 'inside' }, - function (dataZoomModel: InsideZoomModel) { - const dzReferCoordSysWrap = collectReferCoordSysModelInfo(dataZoomModel); - - each(dzReferCoordSysWrap.infoList, function (dzCoordSysInfo) { - - const coordSysUid = dzCoordSysInfo.model.uid; - const coordSysRecord = coordSysRecordMap.get(coordSysUid) - || coordSysRecordMap.set(coordSysUid, createCoordSysRecord(api, dzCoordSysInfo.model)); - - const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap - || (coordSysRecord.dataZoomInfoMap = createHashMap()); - // Notice these props might be changed each time for a single dataZoomModel. - dataZoomInfoMap.set(dataZoomModel.uid, { - dzReferCoordSysInfo: dzCoordSysInfo, - model: dataZoomModel, - getRange: null - }); - }); - } - ); + coordSysRecordMap.each(function (coordSysRecord) { + // `coordSysRecordMap` always exists (because it holds the `roam controller`, which should + // better not re-create each time), but clear `dataZoomInfoMap` each round of the workflow. + coordSysRecord.dataZoomInfoMap = null; + }); - // (1) Merge dataZoom settings for each coord sys and set to the roam controller. - // (2) Clear coord sys if not refered by any dataZoom. - coordSysRecordMap.each(function (coordSysRecord) { - const controller = coordSysRecord.controller; - let firstDzInfo: DataZoomInfo; - const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap; - - if (dataZoomInfoMap) { - const firstDzKey = dataZoomInfoMap.keys()[0]; - if (firstDzKey != null) { - firstDzInfo = dataZoomInfoMap.get(firstDzKey); - } - } + ecModel.eachComponent( + { mainType: 'dataZoom', subType: 'inside' }, + function (dataZoomModel: InsideZoomModel) { + const dzReferCoordSysWrap = collectReferCoordSysModelInfo(dataZoomModel); - if (!firstDzInfo) { - disposeCoordSysRecord(coordSysRecordMap, coordSysRecord); - return; + each(dzReferCoordSysWrap.infoList, function (dzCoordSysInfo) { + + const coordSysUid = dzCoordSysInfo.model.uid; + const coordSysRecord = coordSysRecordMap.get(coordSysUid) + || coordSysRecordMap.set(coordSysUid, createCoordSysRecord(api, dzCoordSysInfo.model)); + + const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap + || (coordSysRecord.dataZoomInfoMap = createHashMap()); + // Notice these props might be changed each time for a single dataZoomModel. + dataZoomInfoMap.set(dataZoomModel.uid, { + dzReferCoordSysInfo: dzCoordSysInfo, + model: dataZoomModel, + getRange: null + }); + }); + } + ); + + // (1) Merge dataZoom settings for each coord sys and set to the roam controller. + // (2) Clear coord sys if not referred by any dataZoom. + coordSysRecordMap.each(function (coordSysRecord) { + const controller = coordSysRecord.controller; + let firstDzInfo: DataZoomInfo; + const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap; + + if (dataZoomInfoMap) { + const firstDzKey = dataZoomInfoMap.keys()[0]; + if (firstDzKey != null) { + firstDzInfo = dataZoomInfoMap.get(firstDzKey); } + } + + if (!firstDzInfo) { + disposeCoordSysRecord(coordSysRecordMap, coordSysRecord); + return; + } - const controllerParams = mergeControllerParams(dataZoomInfoMap, coordSysRecord, api); - controller.enable(controllerParams.controlType, controllerParams.opt); + const controllerParams = mergeControllerParams(dataZoomInfoMap, coordSysRecord, api); + controller.enable(controllerParams.controlType, controllerParams.opt); - throttleUtil.createOrUpdate( - coordSysRecord, - 'dispatchAction', - firstDzInfo.model.get('throttle', true), - 'fixRate' - ); - }); + throttleUtil.createOrUpdate( + coordSysRecord, + 'dispatchAction', + firstDzInfo.model.get('throttle', true), + 'fixRate' + ); + }); }); } diff --git a/src/core/echarts.ts b/src/core/echarts.ts index ad5bb2bdc5..a68bddad01 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -233,30 +233,31 @@ export const PRIORITY = { * * - EC_PROGRESSIVE_CYCLE: * - It also carries out a series of processing/updating/rendering, but out of EC_MAIN_CYCLE. - * - It is performed in each "animation frame". - * - It can be triggered internally or `appendData` call. + * - It is performed in each subsequent "animation frame" until finished. + * - It can be triggered by EC_MAIN_CYCLE or EC_APPEND_DATA_CYCLE. * - A run of EC_PROGRESSIVE_CYCLE comprises: * - Data processing (may be absent) (see `registerProcessor`) * - Visual encoding (may be absent) (see `registerVisual`) * - Layout (may be absent) (see `registerLayout`) * - Rendering (`ComponentView` or `SeriesView`) * - PENDING: currently all data processing tasks (via `registerProcessor`) run in "block" mode. - * (see `performDataProcessorTasks`) + * (see `performDataProcessorTasks`). * * - Other updating/rendering cycles: + * - EC_APPEND_DATA_CYCLE (see `appendData`) is only supported for some special cases. * - Some series have specific update/render cycles. For example, graph force layout performs - * layout and rendering in each "animation frame". + * layout and rendering in each "animation frame". * * - Model updating: * - Model can only be modified at the beginning of ec cycles, including only: * - EC_PREPARE_UPDATE (see method `prepare()`) in `setOption` call. * - EC action handlers in `dispatchAction` call. - * - `appendData` (a special case, where only data is modified). + * - `appendData` (a special case, where only data can be modified). * * - The lifetime of CoordinateSystem/Axis/Scale instances: - * - They are only re-created per run of EC_FULL_UPDATE. + * - They are only re-created per run of EC_FULL_UPDATE in EC_MAIN_CYCLE. * - * - Available caches: see `cycleCache.ts` + * - Global caches: see `cycleCache.ts` */ // See comments in EC_CYCLE. @@ -1612,13 +1613,13 @@ class ECharts extends Eventful { seriesModel.appendData(params); - // Note: `appendData` does not support that update extent of coordinate - // system, util some scenario require that. In the expected usage of - // `appendData`, the initial extent of coordinate system should better - // be fixed by axis `min`/`max` setting or initial data, otherwise if - // the extent changed while `appendData`, the location of the painted - // graphic elements have to be changed, which make the usage of - // `appendData` meaningless. + // NOTICE: + // `appendData` does not support to update axis scale extent of coordinate + // systems. In the expected usage of `appendData`, the initial extent of + // coordinate system should be explicitly specified (by `xxxAxis.data` for + // 'category' axis or by `xxxAxis.min/max` for other axes). Otherwise, if + // the extent keep changing while `appendData`, the location of the painted + // graphic elements have to be changed frequently. this._scheduler.unfinished = true; @@ -1873,6 +1874,8 @@ class ECharts extends Eventful { // In LineView may save the old coordinate system and use it to get the original point. coordSysMgr.create(ecModel, api); + lifecycle.trigger('coordsys:aftercreate', ecModel, api); + scheduler.performDataProcessorTasks(ecModel, payload); // Current stream render is not supported in data process. So we can update diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts index 933aca567f..f42d8b38ba 100644 --- a/src/core/lifecycle.ts +++ b/src/core/lifecycle.ts @@ -54,6 +54,7 @@ export interface UpdateLifecycleParams { } interface LifecycleEvents { 'afterinit': [EChartsType], + 'coordsys:aftercreate': [GlobalModel, ExtensionAPI], 'series:beforeupdate': [GlobalModel, ExtensionAPI, UpdateLifecycleParams], 'series:layoutlabels': [GlobalModel, ExtensionAPI, UpdateLifecycleParams], 'series:transition': [GlobalModel, ExtensionAPI, UpdateLifecycleParams], diff --git a/src/util/model.ts b/src/util/model.ts index 95d7df3d6d..382eeff06d 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -48,13 +48,12 @@ import { OptionName, InterpolatableValue, NullUndefined, - UNDEFINED_STR, } from './types'; import { Dictionary } from 'zrender/src/core/types'; import SeriesModel from '../model/Series'; import CartesianAxisModel from '../coord/cartesian/AxisModel'; import type GridModel from '../coord/cartesian/GridModel'; -import { isNumeric, getRandomIdBase, getPrecision, round, MAX_SAFE_INTEGER } from './number'; +import { isNumeric, getRandomIdBase, getPrecision, round } from './number'; import { error, warn } from './log'; import type Model from '../model/Model'; @@ -718,6 +717,10 @@ export function queryDataIndex(data: SeriesData, payload: Payload & { } /** + * [CAVEAT]: + * DO NOT use it in performance-sensitive scenarios. + * Likely a hash map lookup; not inline-cache friendly. + * * Enable property storage to any host object. * Notice: Serialization is not supported. * diff --git a/src/util/types.ts b/src/util/types.ts index aebc93ce6c..c885c13531 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -385,13 +385,14 @@ export interface StageHandler { * A SERIES_STAGE_TASK is owned by a pipeline and is specific to a single series. * * The `reset` method is called iff this task is "dirty" (See `Task['dirty']`). - * Task `dirty()` call typically originates from: - * - A trigger of EC_MAIN_CYCLE (including EC_FULL_UPDATE and EC_PARTIAL_UPDATE) - * (See comments in EC_CYCLE) - * - NOTICE: `dirtyOnOverallProgress: true` cause that the corresponding `overallReset` - * and `reset` of downsteams tasks may also be called in EC_PROGRESSIVE_CYCLE. - * But in this case, `CoordinateSystem#create` and `CoordinateSystem#update` are - * not called. + * Task `dirty()` call typically originates from a trigger of EC_MAIN_CYCLE (including + * EC_FULL_UPDATE and EC_PARTIAL_UPDATE) (See comments in EC_CYCLE) + * + * NOTICE: `dirtyOnOverallProgress: true` cause that the corresponding `overallReset` + * and `reset` of downsteams tasks may also be called in EC_PROGRESSIVE_CYCLE. + * But in this case, `CoordinateSystem#create` and `CoordinateSystem#update` are not called. + * Only lifecycle like `coordsys:aftercreate` can be ensured to be only called in EC_FULL_UPDATE + * of EC_MAIN_CYCLE, but not in EC_PROGRESSIVE_CYCLE and EC_APPEND_DATA_CYCLE. */ reset?: StageHandlerReset; diff --git a/test/candlestick-large3.html b/test/candlestick-large3.html index 5f58a89b98..6c64b7afd0 100644 --- a/test/candlestick-large3.html +++ b/test/candlestick-large3.html @@ -63,10 +63,11 @@ var xValueMax = rawDataChunkSize * chunkCount; var yValueMin = Infinity; var yValueMax = -Infinity; - + var xData = []; var rawData = []; + for (var i = 0; i < chunkCount; i++) { - rawData.push(generateOHLC(rawDataChunkSize)); + generateOHLC(rawDataChunkSize, rawData); } yValueMax = Math.ceil(yValueMax); yValueMin = Math.floor(yValueMin); @@ -75,7 +76,6 @@ frameInsight.init(echarts, 'duration'); - // var data = generateOHLC(rawDataChunkSize); var chart = window.chart = init(); var loadedChunkIndex = 0; @@ -99,7 +99,7 @@ } function generateOHLC(count) { - var data = []; + var seriesData = []; var tmpVals = new Array(4); var dayRange = 12; @@ -125,10 +125,12 @@ closeIdx++; } + var xValIdx = xData.length; + xData.push(echarts.format.formatTime('yyyy-MM-dd hh:mm:ss', xValue += minute)); // ['open', 'close', 'lowest', 'highest'] // [1, 4, 3, 2] - data.push([ - echarts.format.formatTime('yyyy-MM-dd hh:mm:ss', xValue += minute), + seriesData.push([ + xValIdx, +tmpVals[openIdx].toFixed(2), // open +tmpVals[3].toFixed(2), // highest +tmpVals[0].toFixed(2), // lowest @@ -136,7 +138,7 @@ ]); } - return data; + rawData.push(seriesData); } function calculateMA(dayCount, data) { @@ -208,8 +210,9 @@ axisLine: {onZero: false}, splitLine: {show: false}, splitNumber: 20, - min: xValueMin, - max: xValueMax + // min: xValueMin, + // max: xValueMax, + data: xData, }, // { // type: 'category', From d2cc085b26b130bfcacc1123debb80672c964db1 Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 10 Mar 2026 02:00:01 +0800 Subject: [PATCH 26/31] tweak: Clarity the previous implements of axis statistics. --- src/chart/boxplot/boxplotLayout.ts | 24 +- src/chart/candlestick/candlestickLayout.ts | 11 +- src/chart/helper/axisSnippets.ts | 43 +-- src/chart/scatter/jitterLayout.ts | 7 +- src/component/dataZoom/AxisProxy.ts | 4 +- src/component/singleAxis/install.ts | 4 +- src/coord/axisStatistics.ts | 344 ++++++++++++++------- src/coord/cartesian/Grid.ts | 13 +- src/coord/cartesian/GridModel.ts | 2 +- src/coord/parallel/Parallel.ts | 8 +- src/coord/parallel/ParallelModel.ts | 7 +- src/coord/parallel/parallelCreator.ts | 13 +- src/coord/polar/PolarModel.ts | 3 +- src/coord/polar/polarCreator.ts | 19 +- src/coord/radar/Radar.ts | 21 +- src/coord/radar/RadarModel.ts | 10 +- src/coord/scaleRawExtentInfo.ts | 106 +++---- src/coord/single/AxisModel.ts | 6 +- src/coord/single/Single.ts | 8 +- src/coord/single/singleCreator.ts | 12 +- src/core/echarts.ts | 10 +- src/layout/barCommon.ts | 23 +- src/layout/barGrid.ts | 29 +- src/layout/barPolar.ts | 28 +- src/model/Global.ts | 3 + src/scale/scaleMapper.ts | 4 +- src/util/jitter.ts | 6 +- 27 files changed, 453 insertions(+), 315 deletions(-) diff --git a/src/chart/boxplot/boxplotLayout.ts b/src/chart/boxplot/boxplotLayout.ts index feda1efd55..283c18979d 100644 --- a/src/chart/boxplot/boxplotLayout.ts +++ b/src/chart/boxplot/boxplotLayout.ts @@ -22,7 +22,7 @@ import {parsePercent} from '../../util/number'; import type GlobalModel from '../../model/Global'; import BoxplotSeriesModel, { SERIES_TYPE_BOXPLOT } from './BoxplotSeries'; import { - eachCollectedAxis, eachCollectedSeries, getCollectedSeriesLength, + countSeriesOnAxisOnKey, eachAxisOnKey, eachSeriesOnAxisOnKey, requireAxisStatistics } from '../../coord/axisStatistics'; import { makeCallOnlyOnce } from '../../util/model'; @@ -31,8 +31,9 @@ import Axis from '../../coord/Axis'; import { registerAxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; import { calcBandWidth } from '../../coord/axisBand'; import { - makeAxisStatKey, - createSimpleAxisStatClient, createBandWidthBasedAxisContainShapeHandler + createBandWidthBasedAxisContainShapeHandler, + getMetricsNonOrdinalLinearPositiveMinGap, + makeAxisStatKey } from '../helper/axisSnippets'; @@ -45,13 +46,13 @@ export interface BoxplotItemLayout { export function boxplotLayout(ecModel: GlobalModel) { const axisStatKey = makeAxisStatKey(SERIES_TYPE_BOXPLOT); - eachCollectedAxis(ecModel, axisStatKey, function (axis) { - const seriesCount = getCollectedSeriesLength(axis, axisStatKey); + eachAxisOnKey(ecModel, axisStatKey, function (axis) { + const seriesCount = countSeriesOnAxisOnKey(axis, axisStatKey); if (!seriesCount) { return; } const baseResult = calculateBase(axis, seriesCount); - eachCollectedSeries(axis, axisStatKey, function (seriesModel: BoxplotSeriesModel, idx) { + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel: BoxplotSeriesModel, idx) { layoutSingleSeries( seriesModel, baseResult.boxOffsetList[idx], @@ -77,7 +78,7 @@ function calculateBase(baseAxis: Axis, seriesCount: number): { {fromStat: {key: makeAxisStatKey(SERIES_TYPE_BOXPLOT)}, min: 1}, ).w; - eachCollectedSeries(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel: BoxplotSeriesModel) { + eachSeriesOnAxisOnKey(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel: BoxplotSeriesModel) { let boxWidthBound = seriesModel.get('boxWidth'); if (!isArray(boxWidthBound)) { boxWidthBound = [boxWidthBound, boxWidthBound]; @@ -93,7 +94,7 @@ function calculateBase(baseAxis: Axis, seriesCount: number): { const boxWidth = (availableWidth - boxGap * (seriesCount - 1)) / seriesCount; let base = boxWidth / 2 - availableWidth / 2; - eachCollectedSeries(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel, idx) { + eachSeriesOnAxisOnKey(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel, idx) { boxOffsetList.push(base); base += boxGap + boxWidth; @@ -189,8 +190,11 @@ export function registerBoxplotAxisHandlers(registers: EChartsExtensionInstallRe const axisStatKey = makeAxisStatKey(SERIES_TYPE_BOXPLOT); requireAxisStatistics( registers, - axisStatKey, - createSimpleAxisStatClient(SERIES_TYPE_BOXPLOT) + { + key: axisStatKey, + seriesType: SERIES_TYPE_BOXPLOT, + getMetrics: getMetricsNonOrdinalLinearPositiveMinGap, + } ); registerAxisContainShapeHandler( axisStatKey, diff --git a/src/chart/candlestick/candlestickLayout.ts b/src/chart/candlestick/candlestickLayout.ts index 5abd593abe..67fd4e7100 100644 --- a/src/chart/candlestick/candlestickLayout.ts +++ b/src/chart/candlestick/candlestickLayout.ts @@ -34,7 +34,9 @@ import { import { EChartsExtensionInstallRegisters } from '../../extension'; import { registerAxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; import { - makeAxisStatKey, createSimpleAxisStatClient, createBandWidthBasedAxisContainShapeHandler + createBandWidthBasedAxisContainShapeHandler, + getMetricsNonOrdinalLinearPositiveMinGap, + makeAxisStatKey } from '../helper/axisSnippets'; import { calcBandWidth } from '../../coord/axisBand'; @@ -280,8 +282,11 @@ export function registerCandlestickAxisHandlers(registers: EChartsExtensionInsta const axisStatKey = makeAxisStatKey(SERIES_TYPE_CANDLESTICK); requireAxisStatistics( registers, - axisStatKey, - createSimpleAxisStatClient(SERIES_TYPE_CANDLESTICK) + { + key: axisStatKey, + seriesType: SERIES_TYPE_CANDLESTICK, + getMetrics: getMetricsNonOrdinalLinearPositiveMinGap + } ); registerAxisContainShapeHandler( axisStatKey, diff --git a/src/chart/helper/axisSnippets.ts b/src/chart/helper/axisSnippets.ts index d7b3ac1429..66bcdf53b6 100644 --- a/src/chart/helper/axisSnippets.ts +++ b/src/chart/helper/axisSnippets.ts @@ -17,33 +17,16 @@ * under the License. */ +import type Axis from '../../coord/Axis'; import { calcBandWidth } from '../../coord/axisBand'; -import { AxisStatisticsClient, AxisStatKey } from '../../coord/axisStatistics'; +import { AXIS_STAT_KEY_DELIMITER, AxisStatKey, AxisStatMetrics } from '../../coord/axisStatistics'; +import { CoordinateSystem } from '../../coord/CoordinateSystem'; import { AxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; import { isOrdinalScale } from '../../scale/helper'; import { isNullableNumberFinite } from '../../util/number'; import { ComponentSubType } from '../../util/types'; -export const getMetricsMinGapOnNonCategoryAxis: AxisStatisticsClient['getMetrics'] = function (axis) { - return { - liPosMinGap: !isOrdinalScale(axis.scale) - }; -}; - -export function createSimpleAxisStatClient( - seriesType: ComponentSubType, -): AxisStatisticsClient { - return { - collectAxisSeries(ecModel, saveAxisSeries) { - ecModel.eachSeriesByType(seriesType, function (seriesModel) { - saveAxisSeries(seriesModel.getBaseAxis(), seriesModel); - }); - }, - getMetrics: getMetricsMinGapOnNonCategoryAxis - }; -} - /** * Require `requireAxisStatistics`. */ @@ -57,6 +40,24 @@ export function createBandWidthBasedAxisContainShapeHandler(axisStatKey: AxisSta }; } + +/** + * A pre-built `makeAxisStatKey`. + * See `makeAxisStatKey2`. Use two functions rather than a optional parameter to impose checking. + */ export function makeAxisStatKey(seriesType: ComponentSubType): AxisStatKey { - return seriesType as AxisStatKey; + return (seriesType + AXIS_STAT_KEY_DELIMITER) as AxisStatKey; +} +export function makeAxisStatKey2(seriesType: ComponentSubType, coordSysType: CoordinateSystem['type']): AxisStatKey { + return (seriesType + AXIS_STAT_KEY_DELIMITER + coordSysType) as AxisStatKey; } + +/** + * A pre-built `getMetrics`. + */ +export function getMetricsNonOrdinalLinearPositiveMinGap(axis: Axis): AxisStatMetrics { + return { + // non-category scale do not use `liPosMinGap` to calculate `bandWidth`. + liPosMinGap: !isOrdinalScale(axis.scale) + }; +}; diff --git a/src/chart/scatter/jitterLayout.ts b/src/chart/scatter/jitterLayout.ts index ad2c2e9baf..1da8ce195e 100644 --- a/src/chart/scatter/jitterLayout.ts +++ b/src/chart/scatter/jitterLayout.ts @@ -23,6 +23,8 @@ import type SingleAxis from '../../coord/single/SingleAxis'; import type Axis2D from '../../coord/cartesian/Axis2D'; import type { StageHandler } from '../../util/types'; import createRenderPlanner from '../helper/createRenderPlanner'; +import { COORD_SYS_TYPE_CARTESIAN_2D } from '../../coord/cartesian/GridModel'; +import { COORD_SYS_TYPE_SINGLE_AXIS } from '../../coord/single/AxisModel'; export default function jitterLayout(): StageHandler { return { @@ -32,7 +34,10 @@ export default function jitterLayout(): StageHandler { reset(seriesModel: ScatterSeriesModel) { const coordSys = seriesModel.coordinateSystem; - if (!coordSys || (coordSys.type !== 'cartesian2d' && coordSys.type !== 'single')) { + if (!coordSys || ( + coordSys.type !== COORD_SYS_TYPE_CARTESIAN_2D + && coordSys.type !== COORD_SYS_TYPE_SINGLE_AXIS + )) { return; } const baseAxis = coordSys.getBaseAxis && coordSys.getBaseAxis() as Axis2D | SingleAxis; diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 5b7d22a898..f84880d1c9 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -33,7 +33,7 @@ import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './help import { SINGLE_REFERRING } from '../../util/model'; import { isOrdinalScale, isTimeScale } from '../../scale/helper'; import { - AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoReallyCreate, + AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoCreate, ScaleRawExtentResultForZoom, } from '../../coord/scaleRawExtentInfo'; import { discourageOnAxisZero } from '../../coord/axisHelper'; @@ -351,7 +351,7 @@ class AxisProxy { // Nevertheless, user can set min/max/scale on axes to make extent of axes // consistent. const axis = this.getAxisModel().axis; - scaleRawExtentInfoReallyCreate(this.ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); + scaleRawExtentInfoCreate(this.ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); discourageOnAxisZero(axis, {dz: true}); diff --git a/src/component/singleAxis/install.ts b/src/component/singleAxis/install.ts index b2e6b8dc88..c949a1e532 100644 --- a/src/component/singleAxis/install.ts +++ b/src/component/singleAxis/install.ts @@ -21,7 +21,7 @@ import { EChartsExtensionInstallRegisters, use } from '../../extension'; import ComponentView from '../../view/Component'; import SingleAxisView from '../axis/SingleAxisView'; import axisModelCreator from '../../coord/axisModelCreator'; -import SingleAxisModel from '../../coord/single/AxisModel'; +import SingleAxisModel, { COORD_SYS_TYPE_SINGLE_AXIS } from '../../coord/single/AxisModel'; import singleCreator from '../../coord/single/singleCreator'; import {install as installAxisPointer} from '../axisPointer/install'; import AxisView from '../axis/AxisView'; @@ -43,7 +43,7 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerComponentView(SingleAxisView); registers.registerComponentModel(SingleAxisModel); - axisModelCreator(registers, 'single', SingleAxisModel, SingleAxisModel.defaultOption); + axisModelCreator(registers, COORD_SYS_TYPE_SINGLE_AXIS, SingleAxisModel, SingleAxisModel.defaultOption); registers.registerCoordinateSystem('single', singleCreator); } \ No newline at end of file diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts index ca24546195..77f864b8cb 100644 --- a/src/coord/axisStatistics.ts +++ b/src/coord/axisStatistics.ts @@ -17,13 +17,13 @@ * under the License. */ -import { assert, createHashMap, each, HashMap } from 'zrender/src/core/util'; +import { assert, createHashMap, HashMap, retrieve2 } from 'zrender/src/core/util'; import type GlobalModel from '../model/Global'; import type SeriesModel from '../model/Series'; import { initExtentForUnion, makeCallOnlyOnce, makeInner, } from '../util/model'; -import { DimensionIndex, NullUndefined } from '../util/types'; +import { ComponentSubType, DimensionIndex, NullUndefined } from '../util/types'; import type Axis from './Axis'; import { asc, isNullableNumberFinite } from '../util/number'; import { parseSanitizationFilter, passesSanitizationFilter } from '../data/helper/dataValueHelper'; @@ -36,24 +36,41 @@ import { getCachePerECFullUpdate, getCachePerECPrepare, GlobalModelCachePerECFullUpdate, GlobalModelCachePerECPrepare } from '../util/cycleCache'; +import { CoordinateSystem } from './CoordinateSystem'; const callOnlyOnce = makeCallOnlyOnce(); +// Ensure that it never appears in internal generated uid and pre-defined coordSysType. +export const AXIS_STAT_KEY_DELIMITER = '|&'; const ecModelCacheFullUpdateInner = makeInner<{ - all: AxisStatAll; + + // It stores all pairs, aggregated by axis, based on which axis scale extent is calculated. + // NOTICE: series that has been filtered out are included. + // It is unrelated to `AxisStatKeyedClient`. + axSer: HashMap; + + // AxisStatKey based statistics records. + // Only `AxisStatKeyedClient` concerned pairs are collected, based on which + // statistics are calculated. + keyed: AxisStatKeyed; + // `keys` is only used to quick travel. keys: AxisStatKeys; - axisMapForCheck?: HashMap<1, ComponentModel['uid']>; // Only used in dev mode - seriesMapForCheck?: HashMap<1, ComponentModel['uid']>; // Only used in dev mode + + // Only used in dev mode for duplication checking. + axSerPairCheck?: HashMap<1, string>; + }, GlobalModelCachePerECFullUpdate>(); type AxisStatKeys = HashMap; -type AxisStatAll = HashMap; +type AxisStatKeyed = HashMap; type AxisStatPerKey = HashMap; type AxisStatPerKeyPerAxis = { axis: Axis; // This is series use this axis as base axis and need to be laid out. // The order is determined by the client and must be respected. + // Never be null/undefined. + // series filtered out is included. sers: SeriesModel[]; // For query. The array index is series index. serByIdx: SeriesModel[]; @@ -64,16 +81,14 @@ type AxisStatPerKeyPerAxis = { liPosMinGap?: number | NullUndefined; // metrics corresponds to this record. - metrics?: AxisStatisticsMetrics; - // ecPrepareCache corresponds to this record. - ecPrepare?: AxisStatECPrepareCachePerKeyPerAxis; + metrics?: AxisStatMetrics; }; const ecModelCachePrepareInner = makeInner<{ - all: AxisStatECPrepareCacheAll | NullUndefined; + keyed: AxisStatECPrepareCacheKeyed | NullUndefined; }, GlobalModelCachePerECPrepare>(); -type AxisStatECPrepareCacheAll = HashMap; +type AxisStatECPrepareCacheKeyed = HashMap; type AxisStatECPrepareCachePerKey = HashMap; type AxisStatECPrepareCachePerKeyPerAxis = Pick & { @@ -81,17 +96,20 @@ type AxisStatECPrepareCachePerKeyPerAxis = serUids?: HashMap<1, ComponentModel['uid']> }; -export type AxisStatisticsClient = { - /** - * NOTICE: It is called after series filtering. - */ - collectAxisSeries: ( - ecModel: GlobalModel, - saveAxisSeries: (axis: Axis | NullUndefined, series: SeriesModel) => void - ) => void; - getMetrics: ( - axis: Axis, - ) => AxisStatisticsMetrics; +export type AxisStatKeyedClient = { + + // A key for retrieving result. + key: AxisStatKey; + + // Only the specific `seriesType` is covered. + seriesType: ComponentSubType; + // `true` by default - the pair is collected only if series's base axis is that axis. + baseAxis?: boolean | NullUndefined; + // `NullUndefined` by default - all coordinate systems are covered. + coordSysType?: CoordinateSystem['type'] | NullUndefined; + + // `NullUndefined` return indicates this axis should be omitted. + getMetrics: (axis: Axis) => AxisStatMetrics | NullUndefined; }; /** @@ -99,9 +117,9 @@ export type AxisStatisticsClient = { * designated by `AxisStatKey`. In most case `seriesType` is used as `AxisStatKey`. */ export type AxisStatKey = string & {_: 'AxisStatKey'}; // Nominal to avoid misusing. +type ClientQueryKey = string & {_: 'ClientQueryKey'}; // Nominal to avoid misusing; internal usage. -type AxisStatisticsMetrics = { - // Currently only one metric is required. +export type AxisStatMetrics = { // NOTICE: // May be time-consuming in large data due to some metrics requiring travel and sort of @@ -115,6 +133,8 @@ export type AxisStatisticsResult = Pick< 'liPosMinGap' >; +type AxisStatEachSeriesCb = (seriesModel: SeriesModel, travelIdx: number) => void; + let validateInputAxis: ((axis: Axis) => void) | NullUndefined; if (__DEV__) { validateInputAxis = function (axis) { @@ -127,8 +147,8 @@ function getAxisStatPerKeyPerAxis( axisStatKey: AxisStatKey ): AxisStatPerKeyPerAxis | NullUndefined { const axisModel = axis.model; - const all = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(axisModel.ecModel)).all; - const perKey = all && all.get(axisStatKey); + const keyed = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(axisModel.ecModel)).keyed; + const perKey = keyed && keyed.get(axisStatKey); return perKey && perKey.get(axisModel.uid); } @@ -153,7 +173,7 @@ export function getAxisStatBySeries( validateInputAxis(axis); } const result: AxisStatisticsResult[] = []; - eachPerKeyPerAxis(axis.model.ecModel, function (perKeyPerAxis) { + eachKeyEachAxis(axis.model.ecModel, function (perKeyPerAxis) { for (let idx = 0; idx < seriesList.length; idx++) { if (seriesList[idx] && perKeyPerAxis.serByIdx[seriesList[idx].seriesIndex]) { result.push(wrapStatResult(perKeyPerAxis)); @@ -163,7 +183,7 @@ export function getAxisStatBySeries( return result; } -function eachPerKeyPerAxis( +function eachKeyEachAxis( ecModel: GlobalModel, cb: ( perKeyPerAxis: AxisStatPerKeyPerAxis, @@ -171,8 +191,8 @@ function eachPerKeyPerAxis( axisModelUid: AxisBaseModel['uid'] ) => void ): void { - const all = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).all; - all && all.each(function (perKey, axisStatKey) { + const keyed = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).keyed; + keyed && keyed.each(function (perKey, axisStatKey) { perKey.each(function (perKeyPerAxis, axisModelUid) { cb(perKeyPerAxis, axisStatKey, axisModelUid); }); @@ -185,19 +205,57 @@ function wrapStatResult(record: AxisStatPerKeyPerAxis | NullUndefined): AxisStat }; } -export function eachCollectedSeries( +/** + * NOTE: + * - series declaration order is respected. + * - series filtered out are excluded. + */ +export function eachSeriesOnAxis( + axis: Axis, + cb: AxisStatEachSeriesCb +): void { + if (__DEV__) { + validateInputAxis(axis); + } + const ecModel = axis.model.ecModel; + const seriesOnAxisMap = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).axSer; + seriesOnAxisMap && seriesOnAxisMap.each(function (seriesList) { + eachSeriesDeal(ecModel, seriesList, cb); + }); +} + +export function eachSeriesOnAxisOnKey( axis: Axis, axisStatKey: AxisStatKey, cb: (series: SeriesModel, idx: number) => void ): void { if (__DEV__) { + assert(axisStatKey != null); validateInputAxis(axis); } const perKeyPerAxis = getAxisStatPerKeyPerAxis(axis, axisStatKey); - perKeyPerAxis && each(perKeyPerAxis.sers, cb); + perKeyPerAxis && eachSeriesDeal(axis.model.ecModel, perKeyPerAxis.sers, cb); +} + +function eachSeriesDeal( + ecModel: GlobalModel, + seriesList: SeriesModel[], + cb: AxisStatEachSeriesCb +): void { + for (let i = 0; i < seriesList.length; i++) { + const seriesModel = seriesList[i]; + // Legend-filtered series need to be ignored since series are registered before `legendFilter`. + if (!ecModel.isSeriesFiltered(seriesModel)) { + cb(seriesModel, i); + } + } } -export function getCollectedSeriesLength( +/** + * NOTE: + * - series filtered out are excluded. + */ +export function countSeriesOnAxisOnKey( axis: Axis, axisStatKey: AxisStatKey, ): number { @@ -206,10 +264,17 @@ export function getCollectedSeriesLength( validateInputAxis(axis); } const perKeyPerAxis = getAxisStatPerKeyPerAxis(axis, axisStatKey); - return perKeyPerAxis ? perKeyPerAxis.sers.length : 0; + if (!perKeyPerAxis || !perKeyPerAxis.sers.length) { + return 0; + } + let count = 0; + eachSeriesDeal(axis.model.ecModel, perKeyPerAxis.sers, function () { + count++; + }); + return count; } -export function eachCollectedAxis( +export function eachAxisOnKey( ecModel: GlobalModel, axisStatKey: AxisStatKey, cb: (axis: Axis) => void @@ -217,14 +282,17 @@ export function eachCollectedAxis( if (__DEV__) { assert(axisStatKey != null); } - const all = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).all; - const perKey = all && all.get(axisStatKey); + const keyed = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).keyed; + const perKey = keyed && keyed.get(axisStatKey); perKey && perKey.each(function (perKeyPerAxis) { cb(perKeyPerAxis.axis); }); } -export function eachAxisStatKey( +/** + * NOTICE: Available after `CoordinateSystem['create']` (not included). + */ +export function eachKeyOnAxis( axis: Axis, cb: (axisStatKey: AxisStatKey) => void ): void { @@ -239,75 +307,33 @@ export function eachAxisStatKey( }); } +/** + * NOTICE: this processor may be omitted - it is registered only if required. + */ function performAxisStatisticsOnOverallReset(ecModel: GlobalModel): void { - const ecFullUpdateCache = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)); - const axisStatAll: AxisStatAll = ecFullUpdateCache.all = createHashMap(); - const ecPrepareCache = ecModelCachePrepareInner(getCachePerECPrepare(ecModel)); - const ecPrepareCacheAll = ecPrepareCache.all || (ecPrepareCache.all = createHashMap()); - - axisStatisticsClients.each(function (client, axisStatKey) { - client.collectAxisSeries(ecModel, function saveAxisSeries(axis, series) { - if (!axis) { - return; - } + const ecPrepareCacheKeyed = ecPrepareCache.keyed || (ecPrepareCache.keyed = createHashMap()); - if (__DEV__) { - validateInputAxis(axis); - // - An axis can be associated with multiple `axisStatKey`s. For example, if `axisStatKey`s are - // "candlestick" and "bar", they can be associated with the same "xAxis". - // - Within an individual axis, it is a typically incorrect usage if a pair is - // associated with multiple `perKeyPerAxis`, which may cause repeated calculation and - // performance degradation, had hard to be found without the checking below. For example, If - // `axisStatKey` are "grid-bar" (see `barGrid.ts`) and "polar-bar" (see `barPolar.ts`), and - // a pair is wrongly associated with both "polar-bar" and "grid-bar", the - // relevant statistics will be computed twice. - const axisMapForCheck = ecFullUpdateCache.axisMapForCheck - || (ecFullUpdateCache.axisMapForCheck = createHashMap()); - const seriesMapForCheck = ecFullUpdateCache.seriesMapForCheck - || (ecFullUpdateCache.seriesMapForCheck = createHashMap()); - assert(!axisMapForCheck.get(axis.model.uid) || !seriesMapForCheck.get(series.uid)); - axisMapForCheck.set(axis.model.uid, 1); - seriesMapForCheck.set(series.uid, 1); - } - - const perKey = axisStatAll.get(axisStatKey) || axisStatAll.set(axisStatKey, createHashMap()); - - const axisModelUid = axis.model.uid; - let perKeyPerAxis = perKey.get(axisModelUid); - if (!perKeyPerAxis) { - perKeyPerAxis = perKey.set(axisModelUid, {axis, sers: [], serByIdx: []}); - perKeyPerAxis.metrics = client.getMetrics(axis) || {}; + eachKeyEachAxis(ecModel, function (perKeyPerAxis, axisStatKey, axisModelUid) { + const ecPrepareCachePerKey = ecPrepareCacheKeyed.get(axisStatKey) + || ecPrepareCacheKeyed.set(axisStatKey, createHashMap()); + const ecPreparePerKeyPerAxis = ecPrepareCachePerKey.get(axisModelUid) + || ecPrepareCachePerKey.set(axisModelUid, {}); - const ecPrepareCachePerKey = ecPrepareCacheAll.get(axisStatKey) - || ecPrepareCacheAll.set(axisStatKey, createHashMap()); - perKeyPerAxis.ecPrepare = ecPrepareCachePerKey.get(axisModelUid) - || ecPrepareCachePerKey.set(axisModelUid, {}); - } - // NOTICE: series order should respect to the input order, since it - // matters in some cases (see `axisSnippets.ts` for more details). - perKeyPerAxis.sers.push(series); - perKeyPerAxis.serByIdx[series.seriesIndex] = series; - }); - }); - - const axisStatKeys: AxisStatKeys = ecFullUpdateCache.keys = createHashMap(); - eachPerKeyPerAxis(ecModel, function (perKeyPerAxis, axisStatKey, axisModelUid) { - (axisStatKeys.get(axisModelUid) || axisStatKeys.set(axisModelUid, [])) - .push(axisStatKey); - performStatisticsForRecord(perKeyPerAxis); + performStatisticsForRecord(ecModel, perKeyPerAxis, ecPreparePerKeyPerAxis); }); } function performStatisticsForRecord( + ecModel: GlobalModel, perKeyPerAxis: AxisStatPerKeyPerAxis, + ecPreparePerKeyPerAxis: AxisStatECPrepareCachePerKeyPerAxis ): void { if (!perKeyPerAxis.metrics.liPosMinGap) { return; } const newSerUids: AxisStatECPrepareCachePerKeyPerAxis['serUids'] = createHashMap(); - const ecPreparePerKeyPerAxis = perKeyPerAxis.ecPrepare; const ecPrepareSerUids = ecPreparePerKeyPerAxis.serUids; const ecPrepareLiPosMinGap = ecPreparePerKeyPerAxis.liPosMinGap; let ecPrepareCacheMiss: boolean; @@ -325,26 +351,25 @@ function performStatisticsForRecord( // timeAll[0] = Date.now(); // _EC_PERF_ function eachSeries( - cb: (dimStoreIdx: DimensionIndex, seriesModel: SeriesModel, store: DataStore) => void + cb: (dimStoreIdx: DimensionIndex, seriesModel: SeriesModel, rawDataStore: DataStore) => void ) { - for (let i = 0; i < perKeyPerAxis.sers.length; i++) { - const seriesModel = perKeyPerAxis.sers[i]; - const data = seriesModel.getData(); + eachSeriesDeal(ecModel, perKeyPerAxis.sers, function (seriesModel) { + const rawData = seriesModel.getRawData(); // NOTE: Currently there is no series that a "base axis" can map to multiple dimensions. - const dimStoreIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); + const dimStoreIdx = rawData.getDimensionIndex(rawData.mapDimension(axis.dim)); if (dimStoreIdx >= 0) { - cb(dimStoreIdx, seriesModel, data.getStore()); + cb(dimStoreIdx, seriesModel, rawData.getStore()); } - } + }); } let bufferCapacity = 0; - eachSeries(function (dimStoreIdx, seriesModel, store) { + eachSeries(function (dimStoreIdx, seriesModel, rawDataStore) { newSerUids.set(seriesModel.uid, 1); if (!ecPrepareSerUids || !ecPrepareSerUids.hasKey(seriesModel.uid)) { ecPrepareCacheMiss = true; } - bufferCapacity += store.count(); + bufferCapacity += rawDataStore.count(); }); if (!ecPrepareSerUids || ecPrepareSerUids.keys().length !== newSerUids.keys().length) { @@ -435,6 +460,101 @@ const tmpValueBuffer = tryEnsureTypedArray( 50 // arbitrary. May be expanded if needed. ); +/** + * NOTICE: + * - It must be called in `CoordinateSystem['create']`, before series filtering. + * - It must be called in `seriesIndex` ascending order (series declaration order). + * i.e., iterated by `ecModel.eachSeries`. + * - Every pair can only call this method once. + * + * @see scaleRawExtentInfoCreate in `scaleRawExtentInfo.ts` + */ +export function associateSeriesWithAxis( + axis: Axis | NullUndefined, + seriesModel: SeriesModel, + coordSysType: CoordinateSystem['type'] +): void { + if (!axis) { + return; + } + + const ecModel = seriesModel.ecModel; + const ecFullUpdateCache = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)); + const axisModelUid = axis.model.uid; + + if (__DEV__) { + validateInputAxis(axis); + // - An axis can be associated with multiple `axisStatKey`s. For example, if `axisStatKey`s are + // "candlestick" and "bar", they can be associated with the same "xAxis". + // - Within an individual axis, it is a typically incorrect usage if a pair is + // associated with multiple `perKeyPerAxis`, which may cause repeated calculation and + // performance degradation, had hard to be found without the checking below. For example, If + // `axisStatKey` are "grid-bar" (see `barGrid.ts`) and "polar-bar" (see `barPolar.ts`), and + // a pair is wrongly associated with both "polar-bar" and "grid-bar", the + // relevant statistics will be computed twice. + const axSerPairCheck = ecFullUpdateCache.axSerPairCheck + || (ecFullUpdateCache.axSerPairCheck = createHashMap()); + const pairKey = `${axisModelUid}${AXIS_STAT_KEY_DELIMITER}${seriesModel.uid}`; + assert(!axSerPairCheck.get(pairKey)); + axSerPairCheck.set(pairKey, 1); + } + + const seriesOnAxisMap = ecFullUpdateCache.axSer || (ecFullUpdateCache.axSer = createHashMap()); + const seriesListPerAxis = seriesOnAxisMap.get(axisModelUid) || (seriesOnAxisMap.set(axisModelUid, [])); + if (__DEV__) { + const lastSeries = seriesListPerAxis[seriesListPerAxis.length - 1]; + if (lastSeries) { + // Series order should respect to the input order, since it matters in some cases + // (e.g., see `barGrid.ts` and `barPolar.ts` - ec option declaration order matters). + assert(lastSeries.seriesIndex < seriesModel.seriesIndex); + } + } + seriesListPerAxis.push(seriesModel); + + const seriesType = seriesModel.subType; + const isBaseAxis = seriesModel.getBaseAxis() === axis; + + const client = clientsByQueryKey.get(makeClientQueryKey(seriesType, isBaseAxis, coordSysType)) + || clientsByQueryKey.get(makeClientQueryKey(seriesType, isBaseAxis, null)); + if (!client) { + return; + } + + const keyed: AxisStatKeyed = ecFullUpdateCache.keyed || (ecFullUpdateCache.keyed = createHashMap()); + const keys: AxisStatKeys = ecFullUpdateCache.keys || (ecFullUpdateCache.keys = createHashMap()); + + const axisStatKey = client.key; + const perKey = keyed.get(axisStatKey) || keyed.set(axisStatKey, createHashMap()); + let perKeyPerAxis = perKey.get(axisModelUid); + if (!perKeyPerAxis) { + perKeyPerAxis = perKey.set(axisModelUid, {axis, sers: [], serByIdx: []}); + // They should only be executed for each pair once: + perKeyPerAxis.metrics = client.getMetrics(axis); + (keys.get(axisModelUid) || keys.set(axisModelUid, [])) + .push(axisStatKey); + } + + // series order should respect to the input order. + perKeyPerAxis.sers.push(seriesModel); + perKeyPerAxis.serByIdx[seriesModel.seriesIndex] = seriesModel; +} + +/** + * NOTE: Currently, the scenario is simple enough to look up clients by hash map. + * Otherwise, a caller-provided `filter` may be an alternative if more complex requirements arise. + */ +function makeClientQueryKey( + seriesType: ComponentSubType, + isBaseAxis: boolean | NullUndefined, + coordSysType: CoordinateSystem['type'] | NullUndefined +): ClientQueryKey { + return ( + seriesType + + AXIS_STAT_KEY_DELIMITER + retrieve2(isBaseAxis, true) + + AXIS_STAT_KEY_DELIMITER + (coordSysType || '') + ) as ClientQueryKey; +} + /** * NOTICE: Can only be called in "install" stage. * @@ -442,20 +562,32 @@ const tmpValueBuffer = tryEnsureTypedArray( */ export function requireAxisStatistics( registers: EChartsExtensionInstallRegisters, - axisStatKey: AxisStatKey, - client: AxisStatisticsClient + client: AxisStatKeyedClient ): void { + const queryKey = makeClientQueryKey(client.seriesType, client.baseAxis, client.coordSysType); + if (__DEV__) { - assert(!axisStatisticsClients.get(axisStatKey)); + assert(client.seriesType + && client.key + && !clientsCheckStatKey.get(client.key) + && !clientsByQueryKey.get(queryKey) + ); // More checking is performed in `axSerPairCheck`. + clientsCheckStatKey.set(client.key, 1); } - axisStatisticsClients.set(axisStatKey, client); + clientsByQueryKey.set(queryKey, client); callOnlyOnce(registers, function () { registers.registerProcessor(registers.PRIORITY.PROCESSOR.AXIS_STATISTICS, { + // Theoretically, `appendData` requires to re-calculate them. + dirtyOnOverallProgress: true, overallReset: performAxisStatisticsOnOverallReset }); }); } -const axisStatisticsClients: HashMap = createHashMap(); +let clientsCheckStatKey: HashMap<1, AxisStatKey>; +if (__DEV__) { + clientsCheckStatKey = createHashMap(); +} +const clientsByQueryKey: HashMap = createHashMap(); diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 1a2a2c1318..915e9f0413 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -43,7 +43,7 @@ import Axis2D from './Axis2D'; import {ParsedModelFinder, ParsedModelFinderKnown, SINGLE_REFERRING} from '../../util/model'; // Depends on GridModel, AxisModel, which performs preprocess. -import GridModel, { GridOption, OUTER_BOUNDS_CLAMP_DEFAULT, OUTER_BOUNDS_DEFAULT } from './GridModel'; +import GridModel, { COORD_SYS_TYPE_CARTESIAN_2D, GridOption, OUTER_BOUNDS_CLAMP_DEFAULT, OUTER_BOUNDS_DEFAULT } from './GridModel'; import CartesianAxisModel from './AxisModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; @@ -77,9 +77,10 @@ import { createDimNameMap } from '../../data/helper/SeriesDataSchema'; import type Axis from '../Axis'; import { AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, - scaleRawExtentInfoEnableBoxCoordSysUsage, scaleRawExtentInfoReallyCreate, scaleRawExtentInfoRequireCreate + scaleRawExtentInfoEnableBoxCoordSysUsage, scaleRawExtentInfoCreate } from '../scaleRawExtentInfo'; import { hasBreaks } from '../../scale/break'; +import { associateSeriesWithAxis } from '../axisStatistics'; type Cartesian2DDimensionName = 'x' | 'y'; @@ -134,7 +135,7 @@ class Grid implements CoordinateSystemMaster { const axesMap = this._axesMap; each(this._axesList, function (axis) { - scaleRawExtentInfoReallyCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); const scale = axis.scale; if (isOrdinalScale(scale)) { scale.setSortInfo(axis.model.get('categorySortInfo')); @@ -559,7 +560,7 @@ class Grid implements CoordinateSystemMaster { injectCoordSysByOption({ targetModel: seriesModel, - coordSysType: 'cartesian2d', + coordSysType: COORD_SYS_TYPE_CARTESIAN_2D, coordSysProvider: coordSysProvider }); @@ -594,8 +595,8 @@ class Grid implements CoordinateSystemMaster { ); } if (xAxis && yAxis) { - scaleRawExtentInfoRequireCreate(xAxis, seriesModel); - scaleRawExtentInfoRequireCreate(yAxis, seriesModel); + associateSeriesWithAxis(xAxis, seriesModel, COORD_SYS_TYPE_CARTESIAN_2D); + associateSeriesWithAxis(yAxis, seriesModel, COORD_SYS_TYPE_CARTESIAN_2D); } }, this); diff --git a/src/coord/cartesian/GridModel.ts b/src/coord/cartesian/GridModel.ts index edef4d51a8..654919df55 100644 --- a/src/coord/cartesian/GridModel.ts +++ b/src/coord/cartesian/GridModel.ts @@ -23,7 +23,7 @@ import { ComponentOption, BoxLayoutOptionMixin, ZRColor, ShadowOptionMixin, NullUndefined, ComponentOnCalendarOptionMixin, ComponentOnMatrixOptionMixin } from '../../util/types'; -import Grid from './Grid'; +import type Grid from './Grid'; import { CoordinateSystemHostModel } from '../CoordinateSystem'; import type GlobalModel from '../../model/Global'; import { getLayoutParams, mergeLayoutParam } from '../../util/layout'; diff --git a/src/coord/parallel/Parallel.ts b/src/coord/parallel/Parallel.ts index 53852a7702..be3c3e71e9 100644 --- a/src/coord/parallel/Parallel.ts +++ b/src/coord/parallel/Parallel.ts @@ -31,7 +31,7 @@ import ParallelAxis from './ParallelAxis'; import * as graphic from '../../util/graphic'; import {mathCeil, mathFloor, mathMax, mathMin, mathPI, round} from '../../util/number'; import sliderMove from '../../component/helper/sliderMove'; -import ParallelModel, { ParallelLayoutDirection } from './ParallelModel'; +import ParallelModel, { COORD_SYS_TYPE_PARALLEL, ParallelLayoutDirection } from './ParallelModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { Dictionary, DimensionName, ScaleDataValue } from '../../util/types'; @@ -42,7 +42,7 @@ import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; import { scaleCalcNice } from '../axisNiceTicks'; import { - AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoReallyCreate + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate } from '../scaleRawExtentInfo'; @@ -77,7 +77,7 @@ type SlidedAxisExpandBehavior = 'none' | 'slide' | 'jump'; class Parallel implements CoordinateSystemMaster, CoordinateSystem { - readonly type = 'parallel'; + readonly type = COORD_SYS_TYPE_PARALLEL; /** * key: dimension @@ -149,7 +149,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { update(ecModel: GlobalModel, api: ExtensionAPI): void { each(this.dimensions, function (dim) { const axis = this._axesMap.get(dim); - scaleRawExtentInfoReallyCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); scaleCalcNice(axis); }, this); } diff --git a/src/coord/parallel/ParallelModel.ts b/src/coord/parallel/ParallelModel.ts index 4ca648bb39..080e9d9fb7 100644 --- a/src/coord/parallel/ParallelModel.ts +++ b/src/coord/parallel/ParallelModel.ts @@ -20,7 +20,7 @@ import * as zrUtil from 'zrender/src/core/util'; import ComponentModel from '../../model/Component'; -import Parallel from './Parallel'; +import type Parallel from './Parallel'; import { DimensionName, ComponentOption, BoxLayoutOptionMixin, ComponentOnCalendarOptionMixin, ComponentOnMatrixOptionMixin @@ -31,6 +31,9 @@ import ParallelSeriesModel from '../../chart/parallel/ParallelSeries'; import SeriesModel from '../../model/Series'; +export const COORD_SYS_TYPE_PARALLEL = 'parallel'; +export const COMPONENT_TYPE_PARALLEL = COORD_SYS_TYPE_PARALLEL; + export type ParallelLayoutDirection = 'horizontal' | 'vertical'; export interface ParallelCoordinateSystemOption extends @@ -65,7 +68,7 @@ export interface ParallelCoordinateSystemOption extends class ParallelModel extends ComponentModel { - static type = 'parallel'; + static type = COMPONENT_TYPE_PARALLEL; readonly type = ParallelModel.type; static dependencies = ['parallelAxis']; diff --git a/src/coord/parallel/parallelCreator.ts b/src/coord/parallel/parallelCreator.ts index 1f6ff73925..a9eaac5e8d 100644 --- a/src/coord/parallel/parallelCreator.ts +++ b/src/coord/parallel/parallelCreator.ts @@ -25,17 +25,18 @@ import Parallel from './Parallel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import ParallelModel from './ParallelModel'; +import ParallelModel, { COMPONENT_TYPE_PARALLEL, COORD_SYS_TYPE_PARALLEL } from './ParallelModel'; import { CoordinateSystemMaster } from '../CoordinateSystem'; import ParallelSeriesModel from '../../chart/parallel/ParallelSeries'; import { SINGLE_REFERRING } from '../../util/model'; import { each } from 'zrender/src/core/util'; -import {scaleRawExtentInfoRequireCreate} from '../scaleRawExtentInfo'; +import { associateSeriesWithAxis } from '../axisStatistics'; + function createParallelCoordSys(ecModel: GlobalModel, api: ExtensionAPI): CoordinateSystemMaster[] { const coordSysList: CoordinateSystemMaster[] = []; - ecModel.eachComponent('parallel', function (parallelModel: ParallelModel, idx: number) { + ecModel.eachComponent(COMPONENT_TYPE_PARALLEL, function (parallelModel: ParallelModel, idx: number) { const coordSys = new Parallel(parallelModel, ecModel, api); coordSys.name = 'parallel_' + idx; @@ -49,14 +50,14 @@ function createParallelCoordSys(ecModel: GlobalModel, api: ExtensionAPI): Coordi // Inject the coordinateSystems into seriesModel ecModel.eachSeries(function (seriesModel) { - if ((seriesModel as ParallelSeriesModel).get('coordinateSystem') === 'parallel') { + if ((seriesModel as ParallelSeriesModel).get('coordinateSystem') === COORD_SYS_TYPE_PARALLEL) { const parallelModel = seriesModel.getReferringComponents( - 'parallel', SINGLE_REFERRING + COMPONENT_TYPE_PARALLEL, SINGLE_REFERRING ).models[0] as ParallelModel; const parallel = seriesModel.coordinateSystem = parallelModel.coordinateSystem; if (parallel) { each(parallel.dimensions, function (dim) { - scaleRawExtentInfoRequireCreate(parallel.getAxis(dim), seriesModel); + associateSeriesWithAxis(parallel.getAxis(dim), seriesModel, COORD_SYS_TYPE_PARALLEL); }); } } diff --git a/src/coord/polar/PolarModel.ts b/src/coord/polar/PolarModel.ts index da158d3772..96a6fb174c 100644 --- a/src/coord/polar/PolarModel.ts +++ b/src/coord/polar/PolarModel.ts @@ -22,7 +22,7 @@ import { ComponentOnMatrixOptionMixin } from '../../util/types'; import ComponentModel from '../../model/Component'; -import Polar from './Polar'; +import type Polar from './Polar'; import { AngleAxisModel, RadiusAxisModel } from './AxisModel'; export interface PolarOption extends @@ -33,6 +33,7 @@ export interface PolarOption extends } export const COORD_SYS_TYPE_POLAR = 'polar'; +export const COMPONENT_TYPE_POLAR = COORD_SYS_TYPE_POLAR; class PolarModel extends ComponentModel { static type = COORD_SYS_TYPE_POLAR; diff --git a/src/coord/polar/polarCreator.ts b/src/coord/polar/polarCreator.ts index 152d08506f..f1f3a45e2d 100644 --- a/src/coord/polar/polarCreator.ts +++ b/src/coord/polar/polarCreator.ts @@ -27,7 +27,7 @@ import { determineAxisType, } from '../../coord/axisHelper'; -import PolarModel from './PolarModel'; +import PolarModel, { COMPONENT_TYPE_POLAR, COORD_SYS_TYPE_POLAR } from './PolarModel'; import ExtensionAPI from '../../core/ExtensionAPI'; import GlobalModel from '../../model/Global'; import OrdinalScale from '../../scale/Ordinal'; @@ -42,8 +42,9 @@ import { CategoryAxisBaseOption } from '../axisCommonTypes'; import { createBoxLayoutReference } from '../../util/layout'; import { scaleCalcNice } from '../axisNiceTicks'; import { - AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoReallyCreate, scaleRawExtentInfoRequireCreate + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate } from '../scaleRawExtentInfo'; +import { associateSeriesWithAxis } from '../axisStatistics'; /** * Resize method bound to the polar @@ -85,8 +86,8 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) const angleAxis = polar.getAngleAxis(); const radiusAxis = polar.getRadiusAxis(); - scaleRawExtentInfoReallyCreate(ecModel, angleAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); - scaleRawExtentInfoReallyCreate(ecModel, radiusAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, angleAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, radiusAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); scaleCalcNice(angleAxis); scaleCalcNice(radiusAxis); @@ -131,7 +132,7 @@ const polarCreator = { create: function (ecModel: GlobalModel, api: ExtensionAPI) { const polarList: Polar[] = []; - ecModel.eachComponent('polar', function (polarModel: PolarModel, idx: number) { + ecModel.eachComponent(COMPONENT_TYPE_POLAR, function (polarModel: PolarModel, idx: number) { const polar = new Polar(idx + ''); // Inject resize and update method polar.update = updatePolarScale; @@ -157,9 +158,9 @@ const polarCreator = { polarIndex?: number polarId?: string }>) { - if (seriesModel.get('coordinateSystem') === 'polar') { + if (seriesModel.get('coordinateSystem') === COORD_SYS_TYPE_POLAR) { const polarModel = seriesModel.getReferringComponents( - 'polar', SINGLE_REFERRING + COMPONENT_TYPE_POLAR, SINGLE_REFERRING ).models[0] as PolarModel; if (__DEV__) { @@ -175,8 +176,8 @@ const polarCreator = { } const polar = seriesModel.coordinateSystem = polarModel.coordinateSystem; if (polar) { - scaleRawExtentInfoRequireCreate(polar.getRadiusAxis(), seriesModel); - scaleRawExtentInfoRequireCreate(polar.getAngleAxis(), seriesModel); + associateSeriesWithAxis(polar.getRadiusAxis(), seriesModel, COORD_SYS_TYPE_POLAR); + associateSeriesWithAxis(polar.getAngleAxis(), seriesModel, COORD_SYS_TYPE_POLAR); } } }); diff --git a/src/coord/radar/Radar.ts b/src/coord/radar/Radar.ts index 242b5d0b0c..0710772b34 100644 --- a/src/coord/radar/Radar.ts +++ b/src/coord/radar/Radar.ts @@ -23,7 +23,9 @@ import IndicatorAxis from './IndicatorAxis'; import IntervalScale from '../../scale/Interval'; import * as numberUtil from '../../util/number'; import { CoordinateSystemMaster, CoordinateSystem } from '../CoordinateSystem'; -import RadarModel from './RadarModel'; +import RadarModel, { + COMPONENT_TYPE_RADAR, COORD_SYS_TYPE_RADAR, RADAR_DEFAULT_SPLIT_NUMBER, SERIES_TYPE_RADAR +} from './RadarModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { ScaleDataValue } from '../../util/types'; @@ -32,16 +34,15 @@ import { map, each, isString, isNumber } from 'zrender/src/core/util'; import { scaleCalcAlign } from '../axisAlignTicks'; import { createBoxLayoutReference } from '../../util/layout'; import { - AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoReallyCreate, scaleRawExtentInfoRequireCreate + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate } from '../scaleRawExtentInfo'; import { ensureValidSplitNumber } from '../../scale/helper'; +import { associateSeriesWithAxis } from '../axisStatistics'; -export const RADAR_DEFAULT_SPLIT_NUMBER = 5; - class Radar implements CoordinateSystem, CoordinateSystemMaster { - readonly type: 'radar'; + readonly type = COORD_SYS_TYPE_RADAR; /** * * Radar dimensions @@ -168,7 +169,7 @@ class Radar implements CoordinateSystem, CoordinateSystemMaster { dummyScale.setConfig({interval: 1}); // Force all the axis fixing the maxSplitNumber. each(indicatorAxes, function (indicatorAxis) { - scaleRawExtentInfoReallyCreate(ecModel, indicatorAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, indicatorAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); scaleCalcAlign(indicatorAxis, dummyScale); }); } @@ -192,19 +193,19 @@ class Radar implements CoordinateSystem, CoordinateSystemMaster { static create(ecModel: GlobalModel, api: ExtensionAPI) { const radarList: Radar[] = []; - ecModel.eachComponent('radar', function (radarModel: RadarModel) { + ecModel.eachComponent(COMPONENT_TYPE_RADAR, function (radarModel: RadarModel) { const radar = new Radar(radarModel, ecModel, api); radarList.push(radar); radarModel.coordinateSystem = radar; }); - ecModel.eachSeriesByType('radar', function (radarSeries) { - if (radarSeries.get('coordinateSystem') === 'radar') { + ecModel.eachSeriesByType(SERIES_TYPE_RADAR, function (radarSeries) { + if (radarSeries.get('coordinateSystem') === COORD_SYS_TYPE_RADAR) { // Inject coordinate system // @ts-ignore const radar = radarSeries.coordinateSystem = radarList[radarSeries.get('radarIndex') || 0]; if (radar) { each(radar.getIndicatorAxes(), function (indicatorAxis) { - scaleRawExtentInfoRequireCreate(indicatorAxis, radarSeries); + associateSeriesWithAxis(indicatorAxis, radarSeries, COORD_SYS_TYPE_RADAR); }); } } diff --git a/src/coord/radar/RadarModel.ts b/src/coord/radar/RadarModel.ts index 7fb1fbea01..de7254f4e8 100644 --- a/src/coord/radar/RadarModel.ts +++ b/src/coord/radar/RadarModel.ts @@ -32,13 +32,19 @@ import { } from '../../util/types'; import { AxisBaseOption, CategoryAxisBaseOption, ValueAxisBaseOption } from '../axisCommonTypes'; import { AxisBaseModel } from '../AxisBaseModel'; -import Radar, { RADAR_DEFAULT_SPLIT_NUMBER } from './Radar'; +import type Radar from './Radar'; import {CoordinateSystemHostModel} from '../../coord/CoordinateSystem'; import tokens from '../../visual/tokens'; import { getUID } from '../../util/component'; const valueAxisDefault = axisDefault.value; +export const COORD_SYS_TYPE_RADAR = 'radar'; +export const COMPONENT_TYPE_RADAR = COORD_SYS_TYPE_RADAR; +export const SERIES_TYPE_RADAR = COORD_SYS_TYPE_RADAR; + +export const RADAR_DEFAULT_SPLIT_NUMBER = 5; + function defaultsShow(opt: object, show: boolean) { return zrUtil.defaults({ show: show @@ -104,7 +110,7 @@ export type InnerIndicatorAxisOption = AxisBaseOption & { }; class RadarModel extends ComponentModel implements CoordinateSystemHostModel { - static readonly type = 'radar'; + static readonly type = COMPONENT_TYPE_RADAR; readonly type = RadarModel.type; coordinateSystem: Radar; diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index f378b5da55..607e3639e7 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -27,9 +27,8 @@ import { NumericAxisBaseOptionCommon, NumericAxisBoundaryGapOptionItemValue, } from './axisCommonTypes'; -import { ComponentSubType, DimensionIndex, DimensionName, NullUndefined, ScaleDataValue } from '../util/types'; +import { DimensionIndex, DimensionName, NullUndefined, ScaleDataValue } from '../util/types'; import { isIntervalScale, isLogScale, isOrdinalScale, isTimeScale } from '../scale/helper'; -import type SeriesModel from '../model/Series'; import { makeInner, initExtentForUnion, unionExtentFromNumber, isValidNumberForExtent, extentHasValue, @@ -46,7 +45,7 @@ import { error } from '../util/log'; import type Axis from './Axis'; import { mathMax, mathMin } from '../util/number'; import { SCALE_EXTENT_KIND_MAPPING } from '../scale/scaleMapper'; -import { AxisStatKey, eachAxisStatKey } from './axisStatistics'; +import { AxisStatKey, eachKeyOnAxis, eachSeriesOnAxis } from './axisStatistics'; /** @@ -58,8 +57,6 @@ import { AxisStatKey, eachAxisStatKey } from './axisStatistics'; */ const scaleInner = makeInner<{ extent: number[]; - // series on this axis to union data extent. - seriesList: SeriesModel[]; dimIdxInCoord: number; }, Scale>(); @@ -502,31 +499,58 @@ function parseBoundaryGapOptionItem( ) || 0; } +/** + * NOTE: `associateSeriesWithAxis` is not necessarily called, e.g., when + * an axis is not used by any series. + */ +function ensureScaleStore(axisLike: {scale: Scale}) { + const store = scaleInner(axisLike.scale); + if (!store.extent) { + store.extent = initExtentForUnion(); + } + return store; +} + +/** + * This supports union extent on case like: pie (or other similar series) + * lays out on cartesian2d. + * @see scaleRawExtentInfoCreate + */ +export function scaleRawExtentInfoEnableBoxCoordSysUsage( + axisLike: { + scale: Scale; + dim: DimensionName; + }, + coordSysDimIdxMap: HashMap | NullUndefined +): void { + ensureScaleStore(axisLike).dimIdxInCoord = coordSysDimIdxMap.get(axisLike.dim); +} + /** * @usage * class SomeCoordSys { * static create() { * ecModel.eachSeries(function (seriesModel) { - * scaleRawExtentInfoRequireCreate(axis1, seriesModel, ...); - * scaleRawExtentInfoRequireCreate(axis2, seriesModel, ...); + * associateSeriesWithAxis(axis1, seriesModel, ...); + * associateSeriesWithAxis(axis2, seriesModel, ...); * // ... * }); * } * update() { - * scaleRawExtentInfoReallyCreate(axis1); - * scaleRawExtentInfoReallyCreate(axis2); + * scaleRawExtentInfoCreate(axis1); + * scaleRawExtentInfoCreate(axis2); * } * } * class AxisProxy { * reset() { - * scaleRawExtentInfoReallyCreate(axis1); + * scaleRawExtentInfoCreate(axis1); * } * } * * NOTICE: - * - `scaleRawExtentInfoRequireCreate` should be typically called in: + * - `associateSeriesWithAxis`(in `axisStatistics.ts`) should be called in: * - Coord sys create method. - * - `scaleRawExtentInfoReallyCreate` should be typically called in: + * - `scaleRawExtentInfoCreate` should be typically called in: * - `dataZoom` processor. It require processing like: * 1. Filter series data by dataZoom1; * 2. Union the filtered data and init the extent of the orthogonal axes, which is the 100% of dataZoom2; @@ -536,49 +560,9 @@ function parseBoundaryGapOptionItem( * NOTE: If `dataZoom` exists can cover this series, this data and its extent * has been dataZoom-filtered. Therefore this handling should not before dataZoom. * - The callback of `min`/`max` in ec option should NOT be called multiple times, - * therefore, we initialize `ScaleRawExtentInfo` uniformly in `scaleRawExtentInfoReallyCreate`. + * therefore, we initialize `ScaleRawExtentInfo` uniformly in `scaleRawExtentInfoCreate`. */ -export function scaleRawExtentInfoRequireCreate( - axisLike: { - scale: Scale; - }, - seriesModel: SeriesModel -): void { - ensureScaleStore(axisLike).seriesList.push(seriesModel); -} - -/** - * NOTE: `scaleRawExtentInfoRequireCreate` is not necessarily called, e.g., when - * an axis is not used by any series. - */ -function ensureScaleStore(axisLike: {scale: Scale}) { - const store = scaleInner(axisLike.scale); - if (!store.extent) { - store.extent = initExtentForUnion(); - store.seriesList = []; - } - return store; -} - -/** - * This supports union extent on case like: pie (or other similar series) - * lays out on cartesian2d. - * @see scaleRawExtentInfoRequireCreate - */ -export function scaleRawExtentInfoEnableBoxCoordSysUsage( - axisLike: { - scale: Scale; - dim: DimensionName; - }, - coordSysDimIdxMap: HashMap | NullUndefined -): void { - ensureScaleStore(axisLike).dimIdxInCoord = coordSysDimIdxMap.get(axisLike.dim); -} - -/** - * @see scaleRawExtentInfoRequireCreate - */ -export function scaleRawExtentInfoReallyCreate( +export function scaleRawExtentInfoCreate( ecModel: GlobalModel, axis: Axis, from: AxisExtentInfoBuildFrom @@ -602,12 +586,12 @@ export function scaleRawExtentInfoReallyCreate( return; } - scaleRawExtentInfoReallyCreateDeal(scale, axis, axisDim, model, ecModel, from); + scaleRawExtentInfoCreateDeal(scale, axis, axisDim, model, ecModel, from); calcContainShape(scale, axis, ecModel, scale.rawExtentInfo); } -function scaleRawExtentInfoReallyCreateDeal( +function scaleRawExtentInfoCreateDeal( scale: Scale, axis: Axis, axisDim: DimensionName, @@ -618,11 +602,7 @@ function scaleRawExtentInfoReallyCreateDeal( const scaleStore = ensureScaleStore(axis); const extent = scaleStore.extent; - each(scaleStore.seriesList, function (seriesModel) { - // Legend-filtered series need to be ignored since series are registered before `legendFilter`. - if (ecModel.isSeriesFiltered(seriesModel)) { - return; - } + eachSeriesOnAxis(axis, function (seriesModel) { if (seriesModel.boxCoordinateSystem) { // This supports union extent on case like: pie (or other similar series) // lays out on cartesian2d. @@ -658,7 +638,7 @@ function scaleRawExtentInfoReallyCreateDeal( const rawExtentInfo = new ScaleRawExtentInfo(scale, model, extent); injectScaleRawExtentInfo(scale, rawExtentInfo, from); - scaleStore.seriesList = scaleStore.extent = null; // Clean up + scaleStore.extent = null; // Clean up } /** @@ -791,7 +771,7 @@ function calcContainShape( // `NullUndefined` indicates that `linearSupplement` is not introduced. let linearSupplement: number[] | NullUndefined; - eachAxisStatKey(axis, function (axisStatKey) { + eachKeyOnAxis(axis, function (axisStatKey) { const handler = axisContainShapeHandlerMap.get(axisStatKey); if (handler) { const singleLinearSupplement = handler(axis, scale, ecModel); diff --git a/src/coord/single/AxisModel.ts b/src/coord/single/AxisModel.ts index a65e149c88..581edf1bb2 100644 --- a/src/coord/single/AxisModel.ts +++ b/src/coord/single/AxisModel.ts @@ -29,6 +29,10 @@ import { import { AxisBaseModel } from '../AxisBaseModel'; import { mixin } from 'zrender/src/core/util'; + +export const COORD_SYS_TYPE_SINGLE_AXIS = 'singleAxis'; +export const COMPONENT_TYPE_SINGLE_AXIS = COORD_SYS_TYPE_SINGLE_AXIS; + export type SingleAxisPosition = 'top' | 'bottom' | 'left' | 'right'; export type SingleAxisOption = AxisBaseOption & BoxLayoutOptionMixin & { @@ -39,7 +43,7 @@ export type SingleAxisOption = AxisBaseOption & BoxLayoutOptionMixin & { class SingleAxisModel extends ComponentModel implements AxisBaseModel { - static type = 'singleAxis'; + static type = COMPONENT_TYPE_SINGLE_AXIS; type = SingleAxisModel.type; static readonly layoutMode = 'box'; diff --git a/src/coord/single/Single.ts b/src/coord/single/Single.ts index d69004e8ff..7c2e804e48 100644 --- a/src/coord/single/Single.ts +++ b/src/coord/single/Single.ts @@ -28,14 +28,14 @@ import { CoordinateSystem, CoordinateSystemMaster } from '../CoordinateSystem'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import BoundingRect from 'zrender/src/core/BoundingRect'; -import SingleAxisModel from './AxisModel'; +import SingleAxisModel, { COORD_SYS_TYPE_SINGLE_AXIS } from './AxisModel'; import { ParsedModelFinder, ParsedModelFinderKnown } from '../../util/model'; import { ScaleDataValue } from '../../util/types'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; import { scaleCalcNice } from '../axisNiceTicks'; import { - AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoReallyCreate + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate } from '../scaleRawExtentInfo'; export const singleDimensions = ['single']; @@ -44,7 +44,7 @@ export const singleDimensions = ['single']; */ class Single implements CoordinateSystem, CoordinateSystemMaster { - readonly type = 'single'; + readonly type = COORD_SYS_TYPE_SINGLE_AXIS; readonly dimension = 'single'; /** @@ -101,7 +101,7 @@ class Single implements CoordinateSystem, CoordinateSystemMaster { */ update(ecModel: GlobalModel, api: ExtensionAPI) { const axis = this._axis; - scaleRawExtentInfoReallyCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); scaleCalcNice(axis); } diff --git a/src/coord/single/singleCreator.ts b/src/coord/single/singleCreator.ts index 88c41cd13b..e1821cb205 100644 --- a/src/coord/single/singleCreator.ts +++ b/src/coord/single/singleCreator.ts @@ -24,11 +24,11 @@ import Single, { singleDimensions } from './Single'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import SingleAxisModel from './AxisModel'; +import SingleAxisModel, { COMPONENT_TYPE_SINGLE_AXIS, COORD_SYS_TYPE_SINGLE_AXIS } from './AxisModel'; import SeriesModel from '../../model/Series'; import { SeriesOption } from '../../util/types'; import { SINGLE_REFERRING } from '../../util/model'; -import { scaleRawExtentInfoRequireCreate } from '../scaleRawExtentInfo'; +import { associateSeriesWithAxis } from '../axisStatistics'; /** * Create single coordinate system and inject it into seriesModel. @@ -36,7 +36,7 @@ import { scaleRawExtentInfoRequireCreate } from '../scaleRawExtentInfo'; function create(ecModel: GlobalModel, api: ExtensionAPI) { const singles: Single[] = []; - ecModel.eachComponent('singleAxis', function (axisModel: SingleAxisModel, idx: number) { + ecModel.eachComponent(COMPONENT_TYPE_SINGLE_AXIS, function (axisModel: SingleAxisModel, idx: number) { const single = new Single(axisModel, ecModel, api); single.name = 'single_' + idx; @@ -50,13 +50,13 @@ function create(ecModel: GlobalModel, api: ExtensionAPI) { singleAxisIndex?: number singleAxisId?: string }>) { - if (seriesModel.get('coordinateSystem') === 'singleAxis') { + if (seriesModel.get('coordinateSystem') === COORD_SYS_TYPE_SINGLE_AXIS) { const singleAxisModel = seriesModel.getReferringComponents( - 'singleAxis', SINGLE_REFERRING + COMPONENT_TYPE_SINGLE_AXIS, SINGLE_REFERRING ).models[0] as SingleAxisModel; const single = seriesModel.coordinateSystem = singleAxisModel && singleAxisModel.coordinateSystem; if (single) { - scaleRawExtentInfoRequireCreate(single.getAxis(), seriesModel); + associateSeriesWithAxis(single.getAxis(), seriesModel, COORD_SYS_TYPE_SINGLE_AXIS); } } }); diff --git a/src/core/echarts.ts b/src/core/echarts.ts index a68bddad01..b2a8872936 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -154,17 +154,18 @@ export const dependencies = { const TEST_FRAME_REMAIN_TIME = 1; const PRIORITY_PROCESSOR_SERIES_FILTER = 800; -// Axis statistics require filtered series. -const PRIORITY_PROCESSOR_AXIS_STATISTICS = 810; // In the current impl, "data stack" will modifies the original "series data extent". Some data // processors rely on the stack result dimension to calculate extents. So data stack // should be in front of other data processors. const PRIORITY_PROCESSOR_DATASTACK = 900; +// AXIS_STATISTICS should be after SERIES_FILTER, as it may change the statistics result (like min gap). +// AXIS_STATISTICS should be before filter (dataZoom), as dataZoom require the result in "containShape" calculation. +const PRIORITY_PROCESSOR_AXIS_STATISTICS = 920; // `PRIORITY_PROCESSOR_FILTER` is typically used by `dataZoom` (see `AxisProxy`), which relies // on the initialized "axis extent". const PRIORITY_PROCESSOR_FILTER = 1000; const PRIORITY_PROCESSOR_DEFAULT = 2000; -const PRIORITY_PROCESSOR_STATISTIC = 5000; +const PRIORITY_PROCESSOR_STATISTICS = 5000; // NOTICE: Data processors above block the stream (especially time-consuming processors like data filters). const PRIORITY_VISUAL_LAYOUT = 1000; @@ -188,7 +189,8 @@ export const PRIORITY = { SERIES_FILTER: PRIORITY_PROCESSOR_SERIES_FILTER, AXIS_STATISTICS: PRIORITY_PROCESSOR_AXIS_STATISTICS, FILTER: PRIORITY_PROCESSOR_FILTER, - STATISTIC: PRIORITY_PROCESSOR_STATISTIC + STATISTIC: PRIORITY_PROCESSOR_STATISTICS, // naming - backward compatibility. + STATISTICS: PRIORITY_PROCESSOR_STATISTICS, }, VISUAL: { LAYOUT: PRIORITY_VISUAL_LAYOUT, diff --git a/src/layout/barCommon.ts b/src/layout/barCommon.ts index 3c9d7699cc..0f8dd1b8cf 100644 --- a/src/layout/barCommon.ts +++ b/src/layout/barCommon.ts @@ -17,7 +17,7 @@ * under the License. */ -import { getMetricsMinGapOnNonCategoryAxis } from '../chart/helper/axisSnippets'; +import { getMetricsNonOrdinalLinearPositiveMinGap } from '../chart/helper/axisSnippets'; import type Axis from '../coord/Axis'; import { AxisStatKey, requireAxisStatistics } from '../coord/axisStatistics'; import { EChartsExtensionInstallRegisters } from '../extension'; @@ -28,28 +28,19 @@ export type BaseBarSeriesSubType = 'bar' | 'pictorialBar'; export const BAR_SERIES_TYPE = 'bar'; -export function registerAxisStatisticsForBaseBar( +export function requireAxisStatisticsForBaseBar( registers: EChartsExtensionInstallRegisters, axisStatKey: AxisStatKey, seriesType: BaseBarSeriesSubType, coordSysType: 'cartesian2d' | 'polar' -) { +): void { requireAxisStatistics( registers, - axisStatKey, { - collectAxisSeries(ecModel, saveAxisSeries) { - // NOTICE: The order of series matters - must be respected to the declaration on ec option, - // because for historical reason, in `barGrid.ts`, the last series holds the effective ec option. - // (See `calcBarWidthAndOffset` in `barGrid.ts`). - ecModel.eachSeriesByType(seriesType, function (seriesModel) { - const coordSys = seriesModel.coordinateSystem; - if (coordSys && coordSys.type === coordSysType) { - saveAxisSeries(seriesModel.getBaseAxis(), seriesModel); - } - }); - }, - getMetrics: getMetricsMinGapOnNonCategoryAxis, + key: axisStatKey, + seriesType, + coordSysType, + getMetrics: getMetricsNonOrdinalLinearPositiveMinGap } ); } diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index a1fa83b4aa..dd1368aa24 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -43,15 +43,15 @@ import { } from '../coord/scaleRawExtentInfo'; import { EChartsExtensionInstallRegisters } from '../extension'; import { - AxisStatKey, - eachCollectedAxis, - eachCollectedSeries, getCollectedSeriesLength + eachAxisOnKey, + eachSeriesOnAxisOnKey, countSeriesOnAxisOnKey, } from '../coord/axisStatistics'; import { AxisBandWidthResult, calcBandWidth } from '../coord/axisBand'; -import { BaseBarSeriesSubType, getStartValue, registerAxisStatisticsForBaseBar } from './barCommon'; +import { BaseBarSeriesSubType, getStartValue, requireAxisStatisticsForBaseBar } from './barCommon'; import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel'; +import { makeAxisStatKey2 } from '../chart/helper/axisSnippets'; const callOnlyOnce = makeCallOnlyOnce(); @@ -171,14 +171,15 @@ function createLayoutInfoListOnAxis( seriesType: BaseBarSeriesSubType ): BarGridLayoutAxisInfo { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D); const seriesInfoOnAxis: BarGridLayoutAxisSeriesInfo[] = []; const bandWidthResult = calcBandWidth( baseAxis, - {fromStat: {key: makeAxisStatKey(seriesType)}, min: 1} + {fromStat: {key: axisStatKey}, min: 1} ); const bandWidth = bandWidthResult.w; - eachCollectedSeries(baseAxis, makeAxisStatKey(seriesType), function (seriesModel: BaseBarSeriesModel) { + eachSeriesOnAxisOnKey(baseAxis, axisStatKey, function (seriesModel: BaseBarSeriesModel) { seriesInfoOnAxis.push({ barWidth: parsePercent(seriesModel.get('barWidth'), bandWidth), barMaxWidth: parsePercent(seriesModel.get('barMaxWidth'), bandWidth), @@ -353,14 +354,14 @@ function calcBarWidthAndOffset( } export function layout(seriesType: BaseBarSeriesSubType, ecModel: GlobalModel): void { - const axisStatKey = makeAxisStatKey(seriesType); - eachCollectedAxis(ecModel, axisStatKey, function (axis: Axis2D) { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D); + eachAxisOnKey(ecModel, axisStatKey, function (axis: Axis2D) { if (__DEV__) { assert(axis instanceof Axis2D); } const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); - eachCollectedSeries(axis, axisStatKey, function (seriesModel) { + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel) { const columnLayoutInfo = columnLayout.columnMap[getSeriesStackId(seriesModel)]; seriesModel.getData().setLayout({ bandWidth: columnLayoutInfo.bandWidth, @@ -520,7 +521,7 @@ function barGridCreateAxisContainShapeHandler(seriesType: BaseBarSeriesSubType): // If bars are placed on 'time', 'value', 'log' axis, handle bars overflow here. // See #6728, #4862, `test/bar-overflow-time-plot.html` if (axis && axis instanceof Axis2D && !isOrdinalScale(scale)) { - if (!getCollectedSeriesLength(axis, makeAxisStatKey(seriesType))) { + if (!countSeriesOnAxisOnKey(axis, makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D))) { return; // Quick path - in most cases there is no bar on non-ordinal axis. } const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); @@ -571,16 +572,12 @@ function calcShapeOverflowSupplement( } } -function makeAxisStatKey(seriesType: BaseBarSeriesSubType): AxisStatKey { - return `barGrid-${seriesType}` as AxisStatKey; -} - export function registerBarGridAxisHandlers(registers: EChartsExtensionInstallRegisters) { callOnlyOnce(registers, function () { function register(seriesType: BaseBarSeriesSubType): void { - const axisStatKey = makeAxisStatKey(seriesType); - registerAxisStatisticsForBaseBar( + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D); + requireAxisStatisticsForBaseBar( registers, axisStatKey, seriesType, diff --git a/src/layout/barPolar.ts b/src/layout/barPolar.ts index 357e24c6f2..2cdf2eae15 100644 --- a/src/layout/barPolar.ts +++ b/src/layout/barPolar.ts @@ -27,12 +27,12 @@ import GlobalModel from '../model/Global'; import ExtensionAPI from '../core/ExtensionAPI'; import { Dictionary } from '../util/types'; import { calcBandWidth } from '../coord/axisBand'; -import { createBandWidthBasedAxisContainShapeHandler } from '../chart/helper/axisSnippets'; +import { createBandWidthBasedAxisContainShapeHandler, makeAxisStatKey2 } from '../chart/helper/axisSnippets'; import { makeCallOnlyOnce } from '../util/model'; import { EChartsExtensionInstallRegisters } from '../extension'; import { registerAxisContainShapeHandler } from '../coord/scaleRawExtentInfo'; -import { getStartValue, registerAxisStatisticsForBaseBar } from './barCommon'; -import { AxisStatKey, eachCollectedAxis, eachCollectedSeries } from '../coord/axisStatistics'; +import { getStartValue, requireAxisStatisticsForBaseBar } from './barCommon'; +import { eachAxisOnKey, eachSeriesOnAxisOnKey } from '../coord/axisStatistics'; import { COORD_SYS_TYPE_POLAR } from '../coord/polar/PolarModel'; import type Axis from '../coord/Axis'; import { assert, each } from 'zrender/src/core/util'; @@ -64,9 +64,9 @@ function getSeriesStackId(seriesModel: BarSeriesModel) { } export function barLayoutPolar(seriesType: 'bar', ecModel: GlobalModel, api: ExtensionAPI) { - const axisStatKey = makeAxisStatKey(seriesType); + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_POLAR); - eachCollectedAxis(ecModel, axisStatKey, function (axis: PolarAxis) { + eachAxisOnKey(ecModel, axisStatKey, function (axis: PolarAxis) { if (__DEV__) { assert((axis instanceof AngleAxis) || axis instanceof RadiusAxis); } @@ -74,7 +74,7 @@ export function barLayoutPolar(seriesType: 'bar', ecModel: GlobalModel, api: Ext const barWidthAndOffset = calcRadialBar(axis, seriesType); const lastStackCoords: LastStackCoords = {}; - eachCollectedSeries(axis, axisStatKey, function (seriesModel: BarSeriesModel) { + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel: BarSeriesModel) { layoutPerAxisPerSeries(axis, seriesModel, barWidthAndOffset, lastStackCoords); }); }); @@ -93,7 +93,7 @@ function layoutPerAxisPerSeries( const columnWidth = columnLayoutInfo.width; const polar = seriesModel.coordinateSystem as Polar; if (__DEV__) { - assert(polar.type === 'polar'); + assert(polar.type === COORD_SYS_TYPE_POLAR); } const valueAxis = polar.getOtherAxis(baseAxis); @@ -209,9 +209,11 @@ function layoutPerAxisPerSeries( * Calculate bar width and offset for radial bar charts */ function calcRadialBar(axis: Axis, seriesType: 'bar'): BarWidthAndOffsetOnAxis { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_POLAR); + const bandWidth = calcBandWidth( axis, - {fromStat: {key: makeAxisStatKey(seriesType)}, min: 1} + {fromStat: {key: axisStatKey}, min: 1} ).w; let remainedWidth: number = bandWidth; @@ -220,7 +222,7 @@ function calcRadialBar(axis: Axis, seriesType: 'bar'): BarWidthAndOffsetOnAxis { let gapOption: string | number = '30%'; const stacks: Dictionary = {}; - eachCollectedSeries(axis, makeAxisStatKey(seriesType), function (seriesModel: BarSeriesModel, idx) { + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel: BarSeriesModel) { const stackId = getSeriesStackId(seriesModel); if (!stacks[stackId]) { @@ -308,17 +310,13 @@ function calcRadialBar(axis: Axis, seriesType: 'bar'): BarWidthAndOffsetOnAxis { return result; } -function makeAxisStatKey(seriesType: 'bar'): AxisStatKey { - return `barPolar-${seriesType}` as AxisStatKey; -} - export function registerBarPolarAxisHandlers( registers: EChartsExtensionInstallRegisters, seriesType: 'bar' // Currently only 'bar' is supported. ): void { callOnlyOnce(registers, function () { - const axisStatKey = makeAxisStatKey(seriesType); - registerAxisStatisticsForBaseBar( + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_POLAR); + requireAxisStatisticsForBaseBar( registers, axisStatKey, seriesType, diff --git a/src/model/Global.ts b/src/model/Global.ts index 33b92ba3f3..1b5c741c28 100644 --- a/src/model/Global.ts +++ b/src/model/Global.ts @@ -849,6 +849,9 @@ echarts.use([${seriesImportName}]);`); return each(this.getSeriesByType(subType), cb, context); } + /** + * It means "filtered out". + */ isSeriesFiltered(seriesModel: SeriesModel): boolean { assertSeriesInitialized(this); return this._seriesIndicesMap.get(seriesModel.componentIndex) == null; diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts index 8455944308..da8b02bee6 100644 --- a/src/scale/scaleMapper.ts +++ b/src/scale/scaleMapper.ts @@ -232,8 +232,8 @@ export interface ScaleMapperGeneric { * * [The steps of extent construction in EC_MAIN_CYCLE]: * - step#1. At `CoordinateSystem#create` stage, requirements of collecting series data extents are - * committed to `scaleRawExtentInfoRequireCreate`, and `Scale` instances are created. - * - step#2. Call `scaleRawExtentInfoReallyCreate` to really collect series data extent and create + * committed to `associateSeriesWithAxis`, and `Scale` instances are created. + * - step#2. Call `scaleRawExtentInfoCreate` to really collect series data extent and create * `ScaleRawExtentInfo` instances to manage extent related configurations * - at "data processing" stage for dataZoom controlled axes, if any, or * - at "CoordinateSystem#update" stage for all other axes. diff --git a/src/util/jitter.ts b/src/util/jitter.ts index 36bbda5c0a..89460bdaa4 100644 --- a/src/util/jitter.ts +++ b/src/util/jitter.ts @@ -21,6 +21,8 @@ import type Axis from '../coord/Axis'; import { calcBandWidth } from '../coord/axisBand'; import type { AxisBaseModel } from '../coord/AxisBaseModel'; import Axis2D from '../coord/cartesian/Axis2D'; +import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel'; +import { COORD_SYS_TYPE_SINGLE_AXIS } from '../coord/single/AxisModel'; import type SingleAxis from '../coord/single/SingleAxis'; import type SeriesModel from '../model/Series'; import { isOrdinalScale } from '../scale/helper'; @@ -31,8 +33,8 @@ export function needFixJitter(seriesModel: SeriesModel, axis: Axis): boolean { const coordType = coordinateSystem && coordinateSystem.type; const baseAxis = coordinateSystem && coordinateSystem.getBaseAxis && coordinateSystem.getBaseAxis(); const scaleType = baseAxis && baseAxis.scale && baseAxis.scale.type; - const seriesValid = coordType === 'cartesian2d' && scaleType === 'ordinal' - || coordType === 'single'; + const seriesValid = coordType === COORD_SYS_TYPE_CARTESIAN_2D && scaleType === 'ordinal' + || coordType === COORD_SYS_TYPE_SINGLE_AXIS; const axisValid = (axis.model as AxisBaseModel).get('jitter') > 0; return seriesValid && axisValid; From b094f987ddf00b43341c1bf73055c9a8babf9132 Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 10 Mar 2026 17:39:48 +0800 Subject: [PATCH 27/31] fix(toolbox): Fix that toolbox theme cause corresponding icons are always displayed even if not required. See #21176 . --- src/component/toolbox/ToolboxModel.ts | 51 +++++++-- src/model/Component.ts | 4 +- test/lib/testHelper.js | 2 +- test/toolbox-custom.html | 153 ++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/component/toolbox/ToolboxModel.ts b/src/component/toolbox/ToolboxModel.ts index 49debe77b5..27037e3ae9 100644 --- a/src/component/toolbox/ToolboxModel.ts +++ b/src/component/toolbox/ToolboxModel.ts @@ -17,7 +17,6 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import * as featureManager from './featureManager'; import ComponentModel from '../../model/Component'; import { @@ -31,9 +30,13 @@ import { CommonTooltipOption, Dictionary, ComponentOnCalendarOptionMixin, - ComponentOnMatrixOptionMixin + ComponentOnMatrixOptionMixin, + NullUndefined } from '../../util/types'; import tokens from '../../visual/tokens'; +import type GlobalModel from '../../model/Global'; +import type Model from '../../model/Model'; +import { each, extend, merge } from 'zrender/src/core/util'; export interface ToolboxTooltipFormatterParams { @@ -93,19 +96,49 @@ class ToolboxModel extends ComponentModel { ignoreSize: true } as const; - optionUpdated() { - super.optionUpdated.apply(this, arguments as any); - const {ecModel} = this; + private _themeFeatureOption: ToolboxOption['feature']; + + init(option: ToolboxOption, parentModel: Model, ecModel: GlobalModel): void { + // An historical behavior: + // An initial ec option + // chart.setOption( {toolbox: {feature: { featureA: {}, featureB: {}, }} } ) + // indicates the declared toolbox features need to be enabled regardless of whether property + // "show" is explicity specified. But the subsequent `setOption` in merge mode requires property + // "show: false" to be explicity specified if intending to remove features, for example: + // chart.setOption( {toolbox: {feature: { featureA: {show: false}, featureC: {} } ) + // We keep backward compatibility and perform specific processing to prevent theme + // settings from breaking it. + const toolboxOptionInTheme = ecModel.getTheme().get('toolbox'); + const themeFeatureOption = toolboxOptionInTheme ? toolboxOptionInTheme.feature : null; + if (themeFeatureOption) { + // Use extend - the first level of the feature option will be modified later. + this._themeFeatureOption = extend({}, themeFeatureOption); + toolboxOptionInTheme.feature = {}; + } - zrUtil.each(this.option.feature, function (featureOpt, featureName) { + super.init(option, parentModel, ecModel); // merge theme is performed inside it. + + if (themeFeatureOption) { + toolboxOptionInTheme.feature = themeFeatureOption; // Recover + } + } + + optionUpdated() { + each(this.option.feature, function (featureOpt, featureName) { + const themeFeatureOption = this._themeFeatureOption; const Feature = featureManager.getFeature(featureName); if (Feature) { if (Feature.getDefaultOption) { - Feature.defaultOption = Feature.getDefaultOption(ecModel); + Feature.defaultOption = Feature.getDefaultOption(this.ecModel); + } + if (themeFeatureOption && themeFeatureOption[featureName]) { + merge(featureOpt, themeFeatureOption[featureName]); + // Follow the previous behavior, theme is only be merged once. + themeFeatureOption[featureName] = null; } - zrUtil.merge(featureOpt, Feature.defaultOption); + merge(featureOpt, Feature.defaultOption); } - }); + }, this); } static defaultOption: ToolboxOption = { diff --git a/src/model/Component.ts b/src/model/Component.ts index 3ff62785f4..a2a938f25e 100644 --- a/src/model/Component.ts +++ b/src/model/Component.ts @@ -191,7 +191,9 @@ class ComponentModel extends Mode /** * Called immediately after `init` or `mergeOption` of this instance called. */ - optionUpdated(newCptOption: Opt, isInit: boolean): void {} + optionUpdated(newCptOption: Opt, isInit: boolean): void { + // MUST NOT do anything here. + } /** * [How to declare defaultOption]: diff --git a/test/lib/testHelper.js b/test/lib/testHelper.js index fee04790dd..67b95a7bce 100644 --- a/test/lib/testHelper.js +++ b/test/lib/testHelper.js @@ -1819,7 +1819,7 @@ if (theme == null && window.__ECHARTS__DEFAULT__THEME__) { theme = window.__ECHARTS__DEFAULT__THEME__; } - if (theme) { + if (typeof theme === 'string') { require(['theme/' + theme]); } diff --git a/test/toolbox-custom.html b/test/toolbox-custom.html index 5c89fad38c..91eee32b10 100644 --- a/test/toolbox-custom.html +++ b/test/toolbox-custom.html @@ -30,7 +30,10 @@ +
+
+ + + + + + + From 6de824dc04448da902426e37b8988f9354884a36 Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 10 Mar 2026 21:39:33 +0800 Subject: [PATCH 28/31] fix(toolbox): Simplify toolbox and fix that toolbox throw error when remove DataZoom feature. --- src/component/toolbox/ToolboxView.ts | 151 ++++++++++++---------- src/component/toolbox/feature/DataView.ts | 6 +- src/component/toolbox/feature/DataZoom.ts | 7 - src/component/toolbox/featureManager.ts | 8 +- test/toolbox-custom.html | 1 + 5 files changed, 83 insertions(+), 90 deletions(-) diff --git a/src/component/toolbox/ToolboxView.ts b/src/component/toolbox/ToolboxView.ts index d3f96d1b40..8b7c82421a 100644 --- a/src/component/toolbox/ToolboxView.ts +++ b/src/component/toolbox/ToolboxView.ts @@ -17,7 +17,6 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import * as textContain from 'zrender/src/contain/text'; import * as graphic from '../../util/graphic'; import { enterEmphasis, leaveEmphasis } from '../../util/states'; @@ -28,7 +27,7 @@ import ComponentView from '../../view/Component'; import ToolboxModel from './ToolboxModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import { DisplayState, Dictionary, Payload } from '../../util/types'; +import { DisplayState, Dictionary, Payload, NullUndefined } from '../../util/types'; import { ToolboxFeature, getFeature, @@ -42,8 +41,10 @@ import ZRText from 'zrender/src/graphic/Text'; import { getFont } from '../../label/labelStyle'; import { box, createBoxLayoutReference, getLayoutRect, positionElement } from '../../util/layout'; import tokens from '../../visual/tokens'; +import { bind, createHashMap, curry, each, filter, HashMap, isFunction, isString } from 'zrender/src/core/util'; type IconPath = ToolboxFeatureModel['iconPaths'][string]; +type FeatureName = string; type ExtendedPath = IconPath & { __title: string @@ -52,9 +53,15 @@ type ExtendedPath = IconPath & { class ToolboxView extends ComponentView { static type = 'toolbox' as const; - _features: Dictionary; + /** + * Current enabled features, including only features having `show: true`. + */ + _features: HashMap; - _featureNames: string[]; + /** + * Current enabled feature names, including only features having `show: true`. + */ + _featureNames: FeatureName[]; render( toolboxModel: ToolboxModel, @@ -74,35 +81,46 @@ class ToolboxView extends ComponentView { const itemSize = +toolboxModel.get('itemSize'); const isVertical = toolboxModel.get('orient') === 'vertical'; const featureOpts = toolboxModel.get('feature') || {}; - const features = this._features || (this._features = {}); + const features = this._features || (this._features = createHashMap()); - const featureNames: string[] = []; - zrUtil.each(featureOpts, function (opt, name) { - featureNames.push(name); + const newFeatureNames: FeatureName[] = []; // Includes both `show: true/false`. + each(featureOpts, function (opt, name) { + newFeatureNames.push(name); }); - (new DataDiffer(this._featureNames || [], featureNames)) + // Diff by feature name. + (new DataDiffer(this._featureNames || [], newFeatureNames)) .add(processFeature) .update(processFeature) - .remove(zrUtil.curry(processFeature, null)) + .remove(curry(processFeature, null)) .execute(); // Keep for diff. - this._featureNames = featureNames; + this._featureNames = filter(newFeatureNames, function (name) { + return features.hasKey(name); + }); + + function processFeature(newIndex: number | NullUndefined, oldIndex?: number | NullUndefined) { + const isDiffAdd = newIndex != null && oldIndex == null; + const isDiffUpdate = newIndex != null && oldIndex != null; + const isDiffRemove = newIndex == null; - function processFeature(newIndex: number, oldIndex?: number) { - const featureName = featureNames[newIndex]; - const oldName = featureNames[oldIndex]; + const featureName = (isDiffAdd || isDiffUpdate) + ? newFeatureNames[newIndex] + : newFeatureNames[oldIndex]; const featureOpt = featureOpts[featureName]; - const featureModel = new Model(featureOpt, toolboxModel, toolboxModel.ecModel) as ToolboxFeatureModel; - let feature: ToolboxFeature | UserDefinedToolboxFeature; + const featureModel = (isDiffAdd || isDiffUpdate) + ? new Model(featureOpt, toolboxModel, ecModel) as ToolboxFeatureModel + : null; + // `.get('show')` Also considered UserDefinedToolboxFeature + const isFeatureShow = featureModel && featureModel.get('show'); - // FIX#11236, merge feature title from MagicType newOption. TODO: consider seriesIndex ? - if (payload && payload.newTitle != null && payload.featureName === featureName) { - featureOpt.title = payload.newTitle; - } + let feature: ToolboxFeature | UserDefinedToolboxFeature; - if (featureName && !oldName) { // Create + if (isDiffAdd) { // DIFF_ADD + if (!isFeatureShow) { + return; + } if (isUserFeatureName(featureName)) { feature = { onclick: featureModel.option.onclick, @@ -116,35 +134,33 @@ class ToolboxView extends ComponentView { } feature = new Feature(); } - features[featureName] = feature; + features.set(featureName, feature); } - else { - feature = features[oldName]; - // If feature does not exist. - if (!feature) { - return; - } + else { // DIFF_UPDATE or DIFF_REMOVE + feature = features.get(featureName); } - feature.uid = getUID('toolbox-feature'); - feature.model = featureModel; - feature.ecModel = ecModel; - feature.api = api; - const isToolboxFeature = feature instanceof ToolboxFeature; - if (!featureName && oldName) { - isToolboxFeature - && (feature as ToolboxFeature).dispose - && (feature as ToolboxFeature).dispose(ecModel, api); + if (isDiffRemove || !isFeatureShow) { + if (isTooltipFeature(feature) && feature.dispose) { + feature.dispose(ecModel, api); + } + features.removeKey(featureName); return; } - if (!featureModel.get('show') || (isToolboxFeature && (feature as ToolboxFeature).unusable)) { - isToolboxFeature - && (feature as ToolboxFeature).remove - && (feature as ToolboxFeature).remove(ecModel, api); - return; + // FIX#11236, merge feature title from MagicType newOption. TODO: consider seriesIndex ? + if (payload && payload.newTitle != null && payload.featureName === featureName) { + // FIXME: ec option should not be modified here. + featureOpt.title = payload.newTitle; } + if (isDiffAdd) { + feature.uid = getUID('toolbox-feature'); + } + feature.model = featureModel; + feature.ecModel = ecModel; + feature.api = api; + createIconPaths(featureModel, feature, featureName); featureModel.setIconStatus = function (this: ToolboxFeatureModel, iconName: string, status: DisplayState) { @@ -157,10 +173,8 @@ class ToolboxView extends ComponentView { } }; - if (feature instanceof ToolboxFeature) { - if (feature.render) { - feature.render(featureModel, ecModel, api, payload); - } + if (isTooltipFeature(feature) && feature.render) { + feature.render(featureModel, ecModel, api, payload); } } @@ -188,14 +202,14 @@ class ToolboxView extends ComponentView { const titles = featureModel.get('title') || {}; let iconsMap: Dictionary; let titlesMap: Dictionary; - if (zrUtil.isString(icons)) { + if (isString(icons)) { iconsMap = {}; iconsMap[featureName] = icons; } else { iconsMap = icons; } - if (zrUtil.isString(titles)) { + if (isString(titles)) { titlesMap = {}; titlesMap[featureName] = titles as string; } @@ -203,7 +217,7 @@ class ToolboxView extends ComponentView { titlesMap = titles; } const iconPaths: ToolboxFeatureModel['iconPaths'] = featureModel.iconPaths = {}; - zrUtil.each(iconsMap, function (iconStr, iconName) { + each(iconsMap, function (iconStr, iconName) { const path = graphic.createIcon( iconStr, {}, @@ -286,7 +300,7 @@ class ToolboxView extends ComponentView { (featureModel.get(['iconStatus', iconName]) === 'emphasis' ? enterEmphasis : leaveEmphasis)(path); group.add(path); - (path as graphic.Path).on('click', zrUtil.bind( + (path as graphic.Path).on('click', bind( feature.onclick, feature, ecModel, api, iconName )); @@ -331,7 +345,7 @@ class ToolboxView extends ComponentView { const textContent = icon.getTextContent(); const emphasisTextState = textContent && textContent.ensureState('emphasis'); // May be background element - if (emphasisTextState && !zrUtil.isFunction(emphasisTextState) && titleText) { + if (emphasisTextState && !isFunction(emphasisTextState) && titleText) { const emphasisTextStyle = emphasisTextState.style || (emphasisTextState.style = {}); const rect = textContain.getBoundingRect( titleText, ZRText.makeFont(emphasisTextStyle) @@ -363,30 +377,20 @@ class ToolboxView extends ComponentView { api: ExtensionAPI, payload: unknown ) { - zrUtil.each(this._features, function (feature) { - feature instanceof ToolboxFeature - && feature.updateView && feature.updateView(feature.model, ecModel, api, payload); - }); - } - - // updateLayout(toolboxModel, ecModel, api, payload) { - // zrUtil.each(this._features, function (feature) { - // feature.updateLayout && feature.updateLayout(feature.model, ecModel, api, payload); - // }); - // }, - - remove(ecModel: GlobalModel, api: ExtensionAPI) { - zrUtil.each(this._features, function (feature) { - feature instanceof ToolboxFeature - && feature.remove && feature.remove(ecModel, api); + each(this._features, function (feature) { + feature + && feature instanceof ToolboxFeature + && feature.updateView + && feature.updateView(feature.model, ecModel, api, payload); }); - this.group.removeAll(); } dispose(ecModel: GlobalModel, api: ExtensionAPI) { - zrUtil.each(this._features, function (feature) { - feature instanceof ToolboxFeature - && feature.dispose && feature.dispose(ecModel, api); + each(this._features, function (feature) { + feature + && feature instanceof ToolboxFeature + && feature.dispose + && feature.dispose(ecModel, api); }); } } @@ -395,4 +399,9 @@ class ToolboxView extends ComponentView { function isUserFeatureName(featureName: string): boolean { return featureName.indexOf('my') === 0; } + +function isTooltipFeature(feature: ToolboxFeature | UserDefinedToolboxFeature): feature is ToolboxFeature { + return feature instanceof ToolboxFeature; +} + export default ToolboxView; diff --git a/src/component/toolbox/feature/DataView.ts b/src/component/toolbox/feature/DataView.ts index def839af52..7f4c506fc0 100644 --- a/src/component/toolbox/feature/DataView.ts +++ b/src/component/toolbox/feature/DataView.ts @@ -447,12 +447,8 @@ class DataView extends ToolboxFeature { this._dom = root; } - remove(ecModel: GlobalModel, api: ExtensionAPI) { - this._dom && api.getDom().removeChild(this._dom); - } - dispose(ecModel: GlobalModel, api: ExtensionAPI) { - this.remove(ecModel, api); + this._dom && api.getDom().removeChild(this._dom); } static getDefaultOption(ecModel: GlobalModel) { diff --git a/src/component/toolbox/feature/DataZoom.ts b/src/component/toolbox/feature/DataZoom.ts index 8125b6dcc4..0c57324def 100644 --- a/src/component/toolbox/feature/DataZoom.ts +++ b/src/component/toolbox/feature/DataZoom.ts @@ -104,13 +104,6 @@ class DataZoomFeature extends ToolboxFeature { handlers[type].call(this); } - remove( - ecModel: GlobalModel, - api: ExtensionAPI - ) { - this._brushController && this._brushController.unmount(); - } - dispose( ecModel: GlobalModel, api: ExtensionAPI diff --git a/src/component/toolbox/featureManager.ts b/src/component/toolbox/featureManager.ts index 5b16acf618..a1f554355f 100644 --- a/src/component/toolbox/featureManager.ts +++ b/src/component/toolbox/featureManager.ts @@ -73,9 +73,8 @@ interface ToolboxFeature { @@ -84,11 +83,6 @@ abstract class ToolboxFeature; ecModel: GlobalModel; api: ExtensionAPI; - - /** - * If toolbox feature can't be used on some platform. - */ - unusable?: boolean; } export {ToolboxFeature}; diff --git a/test/toolbox-custom.html b/test/toolbox-custom.html index 91eee32b10..0a1b1209f3 100644 --- a/test/toolbox-custom.html +++ b/test/toolbox-custom.html @@ -167,6 +167,7 @@ function createOption() { var option = { + tooltip: {}, xAxis: {}, yAxis: {}, series: { From abb3ad41b76d0b8a828a9dcce4e43e3eea470949 Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 13 Mar 2026 01:34:08 +0800 Subject: [PATCH 29/31] fix: fix previous commit. --- src/coord/axisStatistics.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts index 77f864b8cb..32f01c01c4 100644 --- a/src/coord/axisStatistics.ts +++ b/src/coord/axisStatistics.ts @@ -219,9 +219,7 @@ export function eachSeriesOnAxis( } const ecModel = axis.model.ecModel; const seriesOnAxisMap = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).axSer; - seriesOnAxisMap && seriesOnAxisMap.each(function (seriesList) { - eachSeriesDeal(ecModel, seriesList, cb); - }); + seriesOnAxisMap && eachSeriesDeal(ecModel, seriesOnAxisMap.get(axis.model.uid), cb); } export function eachSeriesOnAxisOnKey( @@ -239,9 +237,12 @@ export function eachSeriesOnAxisOnKey( function eachSeriesDeal( ecModel: GlobalModel, - seriesList: SeriesModel[], + seriesList: SeriesModel[] | NullUndefined, cb: AxisStatEachSeriesCb ): void { + if (!seriesList) { + return; + } for (let i = 0; i < seriesList.length; i++) { const seriesModel = seriesList[i]; // Legend-filtered series need to be ignored since series are registered before `legendFilter`. From 9335851264c527001c8978d0005ed9f385670295 Mon Sep 17 00:00:00 2001 From: 100pah Date: Sun, 15 Mar 2026 22:06:51 +0800 Subject: [PATCH 30/31] fix: (1) Previously hoverLayerThreshold modification does not work. (2) Restrict the triggering of hoverLayer to only canvas renderer. --- src/chart/custom/CustomView.ts | 4 ++-- src/chart/heatmap/HeatmapView.ts | 2 +- src/chart/helper/LineDraw.ts | 2 +- src/chart/helper/SymbolDraw.ts | 2 +- src/component/legend/LegendView.ts | 17 ++--------------- src/core/ExtensionAPI.ts | 8 ++++++++ src/core/Scheduler.ts | 5 +++-- src/core/echarts.ts | 28 ++++++++++++++++++++++++---- src/util/graphic.ts | 8 ++++++++ src/view/Chart.ts | 3 +++ 10 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/chart/custom/CustomView.ts b/src/chart/custom/CustomView.ts index fe50f12ce1..e8f30d6489 100644 --- a/src/chart/custom/CustomView.ts +++ b/src/chart/custom/CustomView.ts @@ -94,7 +94,6 @@ import CustomSeriesModel, { CustomRootElementOption, CustomSeriesOption, CustomCompoundPathOption, - CustomSeriesRenderItemCoordinateSystemAPI } from './CustomSeries'; import { PatternObject } from 'zrender/src/graphic/Pattern'; import { @@ -110,6 +109,7 @@ import type SeriesModel from '../../model/Series'; import { getCustomSeries } from './customSeriesRegister'; import tokens from '../../visual/tokens'; + const EMPHASIS = 'emphasis' as const; const NORMAL = 'normal' as const; const BLUR = 'blur' as const; @@ -290,7 +290,7 @@ export default class CustomChartView extends ChartView { function setIncrementalAndHoverLayer(el: Displayable) { if (!el.isGroup) { el.incremental = true; - el.ensureState('emphasis').hoverLayer = true; + el.ensureState('emphasis').hoverLayer = graphicUtil.HOVER_LAYER_FOR_INCREMENTAL; } } for (let idx = params.start; idx < params.end; idx++) { diff --git a/src/chart/heatmap/HeatmapView.ts b/src/chart/heatmap/HeatmapView.ts index a58406bb49..403332025d 100644 --- a/src/chart/heatmap/HeatmapView.ts +++ b/src/chart/heatmap/HeatmapView.ts @@ -346,7 +346,7 @@ class HeatmapView extends ChartView { // PENDING if (incremental) { // Rect must use hover layer if it's incremental. - rect.states.emphasis.hoverLayer = true; + rect.states.emphasis.hoverLayer = graphic.HOVER_LAYER_FOR_INCREMENTAL; } group.add(rect); diff --git a/src/chart/helper/LineDraw.ts b/src/chart/helper/LineDraw.ts index 94c417f4f7..4e0a83ee1d 100644 --- a/src/chart/helper/LineDraw.ts +++ b/src/chart/helper/LineDraw.ts @@ -172,7 +172,7 @@ class LineDraw { function updateIncrementalAndHover(el: Displayable) { if (!el.isGroup && !isEffectObject(el)) { el.incremental = true; - el.ensureState('emphasis').hoverLayer = true; + el.ensureState('emphasis').hoverLayer = graphic.HOVER_LAYER_FOR_INCREMENTAL; } } diff --git a/src/chart/helper/SymbolDraw.ts b/src/chart/helper/SymbolDraw.ts index 93e13848e1..5889b1af6c 100644 --- a/src/chart/helper/SymbolDraw.ts +++ b/src/chart/helper/SymbolDraw.ts @@ -292,7 +292,7 @@ class SymbolDraw { function updateIncrementalAndHover(el: Displayable) { if (!el.isGroup) { el.incremental = true; - el.ensureState('emphasis').hoverLayer = true; + el.ensureState('emphasis').hoverLayer = graphic.HOVER_LAYER_FOR_INCREMENTAL; } } for (let idx = taskParams.start; idx < taskParams.end; idx++) { diff --git a/src/component/legend/LegendView.ts b/src/component/legend/LegendView.ts index 2dc195fcae..142bb32d68 100644 --- a/src/component/legend/LegendView.ts +++ b/src/component/legend/LegendView.ts @@ -724,25 +724,13 @@ function dispatchSelectAction( dispatchHighlightAction(seriesName, dataName, api, excludeSeriesId); } -function isUseHoverLayer(api: ExtensionAPI) { - const list = api.getZr().storage.getDisplayList(); - let emphasisState: DisplayableState; - let i = 0; - const len = list.length; - while (i < len && !(emphasisState = list[i].states.emphasis)) { - i++; - } - return emphasisState && emphasisState.hoverLayer; -} - function dispatchHighlightAction( seriesName: string, dataName: string, api: ExtensionAPI, excludeSeriesId: string[] ) { - // If element hover will move to a hoverLayer. - if (!isUseHoverLayer(api)) { + if (!api.usingTHL()) { api.dispatchAction({ type: 'highlight', seriesName: seriesName, @@ -758,8 +746,7 @@ function dispatchDownplayAction( api: ExtensionAPI, excludeSeriesId: string[] ) { - // If element hover will move to a hoverLayer. - if (!isUseHoverLayer(api)) { + if (!api.usingTHL()) { api.dispatchAction({ type: 'downplay', seriesName: seriesName, diff --git a/src/core/ExtensionAPI.ts b/src/core/ExtensionAPI.ts index cfba0a270b..5e615c9ea2 100644 --- a/src/core/ExtensionAPI.ts +++ b/src/core/ExtensionAPI.ts @@ -73,6 +73,14 @@ abstract class ExtensionAPI { abstract getViewOfSeriesModel(seriesModel: SeriesModel): ChartView; abstract getModel(): GlobalModel; abstract getECMainCycleVersion(): number; + /** + * PENDING: a temporary method - may be refactored. + * Whether a "threshold hoverLayer" is used. + * `true` means using hover layer due to over `hoverLayerThreshold`. + * Otherwise, if `false`, hover layer may be still used due to progressive (incremental), + * but this method does not need to cover this case. + */ + abstract usingTHL(): boolean; } export default ExtensionAPI; diff --git a/src/core/Scheduler.ts b/src/core/Scheduler.ts index c87690e958..f902f9f015 100644 --- a/src/core/Scheduler.ts +++ b/src/core/Scheduler.ts @@ -34,6 +34,7 @@ import { EChartsType } from './echarts'; import SeriesModel from '../model/Series'; import ChartView from '../view/Chart'; import SeriesData from '../data/SeriesData'; +import { ZRenderType } from 'zrender/src/zrender'; export type GeneralTask = Task; export type SeriesTask = Task; @@ -233,12 +234,12 @@ class Scheduler { }; } - restorePipelines(ecModel: GlobalModel): void { + restorePipelines(zr: ZRenderType, ecModel: GlobalModel): void { const scheduler = this; const pipelineMap = scheduler._pipelineMap = createHashMap(); ecModel.eachSeries(function (seriesModel) { - const progressive = seriesModel.getProgressive(); + const progressive = zr.painter.type === 'canvas' && seriesModel.getProgressive(); const pipelineId = seriesModel.uid; pipelineMap.set(pipelineId, { diff --git a/src/core/echarts.ts b/src/core/echarts.ts index b2a8872936..fc1add4b7f 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -310,6 +310,11 @@ interface PostIniter { (chart: EChartsType): void } +const ecInner = modelUtil.makeInner<{ + // Using hoverLayer due to over hoverLayerThreshold. + usingTHL: boolean; +}, ECharts>(); + type EventMethodName = 'on' | 'off'; function createRegisterEventWithLowercaseECharts(method: EventMethodName) { return function (this: ECharts, ...args: any): ECharts { @@ -1638,7 +1643,7 @@ class ECharts extends Eventful { const scheduler = ecIns._scheduler; - scheduler.restorePipelines(ecIns._model); + scheduler.restorePipelines(ecIns._zr, ecIns._model); scheduler.prepareStageTasks(); prepareView(ecIns, true); @@ -2538,6 +2543,11 @@ class ECharts extends Eventful { function updateHoverLayerStatus(ecIns: ECharts, ecModel: GlobalModel): void { const zr = ecIns._zr; + + if (zr.painter.type !== 'canvas') { + return; + } + const storage = zr.storage; let elCount = 0; @@ -2547,7 +2557,10 @@ class ECharts extends Eventful { } }); - if (elCount > ecModel.get('hoverLayerThreshold') && !env.node && !env.worker) { + const inner = ecInner(ecIns); + const shouldUseHoverLayer = elCount > ecModel.get('hoverLayerThreshold') && !env.node && !env.worker; + + if (inner.usingTHL || shouldUseHoverLayer) { ecModel.eachSeries(function (seriesModel) { if (seriesModel.preventUsingHoverLayer) { return; @@ -2555,12 +2568,16 @@ class ECharts extends Eventful { const chartView = ecIns._chartsMap[seriesModel.__viewId]; if (chartView.__alive) { chartView.eachRendered((el: ECElement) => { - if (el.states.emphasis) { - el.states.emphasis.hoverLayer = true; + const emphasis = el.states.emphasis; + if (emphasis && emphasis.hoverLayer !== graphic.HOVER_LAYER_FOR_INCREMENTAL) { + emphasis.hoverLayer = shouldUseHoverLayer + ? graphic.HOVER_LAYER_FROM_THRESHOLD + : graphic.HOVER_LAYER_NO; } }); } }); + inner.usingTHL = shouldUseHoverLayer; } }; @@ -2726,6 +2743,9 @@ class ECharts extends Eventful { getECMainCycleVersion(): number { return ecIns[EC_MAIN_CYCLE_VERSION_KEY]; } + usingTHL(): boolean { + return ecInner(ecIns).usingTHL; + } })(ecIns); }; diff --git a/src/util/graphic.ts b/src/util/graphic.ts index 45bf8b556a..ef104b7fd1 100644 --- a/src/util/graphic.ts +++ b/src/util/graphic.ts @@ -97,6 +97,14 @@ type ExtendShapeReturn = ReturnType; export const XY = ['x', 'y'] as const; export const WH = ['width', 'height'] as const; +/** + * NOTICE: Only canvas renderer can set these hoverLayer flags. + * @see ElementCommonState['hoverLayer'] + */ +export const HOVER_LAYER_NO = 0; +export const HOVER_LAYER_FROM_THRESHOLD = 1; +export const HOVER_LAYER_FOR_INCREMENTAL = 2; + /** * Extend shape with parameters */ diff --git a/src/view/Chart.ts b/src/view/Chart.ts index 734dece866..a3b9b726c7 100644 --- a/src/view/Chart.ts +++ b/src/view/Chart.ts @@ -249,6 +249,9 @@ function toggleHighlight(data: SeriesData, payload: Payload, state: DisplayState }); } else { + // In progressive mode, `data._graphicEls` has typically no items, + // thereby skipping this hover style changing. + // PENDING: more robust approaches? data.eachItemGraphicEl(function (el) { elSetState(el, state, highlightDigit); }); From 56a32c0bb1db9e0be4eee722b983f7bc03e8f81d Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 16 Mar 2026 02:12:03 +0800 Subject: [PATCH 31/31] fix(axisPointer&tooltip): (1) axisPointer and tooltip should be able to update when mousewheel, since dataZoomInside can modify views on mousewheel, and cause highlighted element to be not able to restore. (2) Fix axisPointer highlighted item can not restore due to outdated dataIndexIndex. --- src/component/axisPointer/AxisPointerView.ts | 3 ++- src/component/axisPointer/axisTrigger.ts | 23 +++++++++++++------- src/component/axisPointer/globalListener.ts | 15 +++++-------- src/component/tooltip/TooltipModel.ts | 2 +- src/util/types.ts | 3 ++- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/component/axisPointer/AxisPointerView.ts b/src/component/axisPointer/AxisPointerView.ts index fef625a518..956d5b79ca 100644 --- a/src/component/axisPointer/AxisPointerView.ts +++ b/src/component/axisPointer/AxisPointerView.ts @@ -31,7 +31,8 @@ class AxisPointerView extends ComponentView { render(globalAxisPointerModel: AxisPointerModel, ecModel: GlobalModel, api: ExtensionAPI) { const globalTooltipModel = ecModel.getComponent('tooltip') as TooltipModel; const triggerOn = globalAxisPointerModel.get('triggerOn') - || (globalTooltipModel && globalTooltipModel.get('triggerOn') || 'mousemove|click'); + // mousewheel can change view by dataZoom. + || (globalTooltipModel && globalTooltipModel.get('triggerOn') || 'mousemove|click|mousewheel'); // Register global listener in AxisPointerView to enable // AxisPointerView to be independent to Tooltip. diff --git a/src/component/axisPointer/axisTrigger.ts b/src/component/axisPointer/axisTrigger.ts index 8cc2457a14..ac83d2307b 100644 --- a/src/component/axisPointer/axisTrigger.ts +++ b/src/component/axisPointer/axisTrigger.ts @@ -73,7 +73,7 @@ type CollectedCoordInfo = ReturnType; type CollectedAxisInfo = CollectedCoordInfo['axesInfo'][string]; interface AxisTriggerPayload extends Payload { - currTrigger?: 'click' | 'mousemove' | 'leave' + currTrigger?: 'click' | 'mousemove' | 'leave' | 'mousewheel' /** * x and y, which are mandatory, specify a point to trigger axisPointer and tooltip. */ @@ -453,7 +453,7 @@ function dispatchHighDownActually( ) { // FIXME // highlight status modification should be a stage of main process? - // (Consider confilct (e.g., legend and axisPointer) and setOption) + // (Consider conflict (e.g., legend and axisPointer) and setOption) const zr = api.getZr(); const highDownKey = 'axisPointerLastHighlights' as const; @@ -465,19 +465,26 @@ function dispatchHighDownActually( each(axesInfo, function (axisInfo, key) { const option = axisInfo.axisPointerModel.option; option.status === 'show' && axisInfo.triggerEmphasis && each(option.seriesDataIndices, function (batchItem) { - const key = batchItem.seriesIndex + ' | ' + batchItem.dataIndex; - newHighlights[key] = batchItem; + newHighlights[batchItem.seriesIndex + '|' + batchItem.dataIndex] = batchItem; }); }); // Diff. - const toHighlight: BatchItem[] = []; - const toDownplay: BatchItem[] = []; + const toHighlight: Pick[] = []; + const toDownplay: Pick[] = []; + function makeHighDownItem(batchItem: BatchItem) { + // `dataIndexInside` should be removed, since the last recorded `dataIndexInside` may have + // been changed if `dataZoomInside` changed the view. Only `dataIndex` will suffice. + return { + seriesIndex: batchItem.seriesIndex, + dataIndex: batchItem.dataIndex, + }; + } each(lastHighlights, function (batchItem, key) { - !newHighlights[key] && toDownplay.push(batchItem); + !newHighlights[key] && toDownplay.push(makeHighDownItem(batchItem)); }); each(newHighlights, function (batchItem, key) { - !lastHighlights[key] && toHighlight.push(batchItem); + !lastHighlights[key] && toHighlight.push(makeHighDownItem(batchItem)); }); toDownplay.length && api.dispatchAction({ diff --git a/src/component/axisPointer/globalListener.ts b/src/component/axisPointer/globalListener.ts index af62ccbfe6..00d83d4094 100644 --- a/src/component/axisPointer/globalListener.ts +++ b/src/component/axisPointer/globalListener.ts @@ -28,7 +28,7 @@ import { Dictionary } from 'zrender/src/core/types'; type DispatchActionMethod = ExtensionAPI['dispatchAction']; type Handler = ( - currTrigger: 'click' | 'mousemove' | 'leave', + currTrigger: 'click' | 'mousemove' | 'mousewheel' | 'leave', event: ZRElementEvent, dispatchAction: DispatchActionMethod ) => void; @@ -59,13 +59,6 @@ interface Pendings { const inner = makeInner(); const each = zrUtil.each; -/** - * @param {string} key - * @param {module:echarts/ExtensionAPI} api - * @param {Function} handler - * param: {string} currTrigger - * param: {Array.} point - */ export function register(key: string, api: ExtensionAPI, handler?: Handler) { if (env.node) { return; @@ -89,6 +82,10 @@ function initGlobalListeners(zr: ZRenderType, api?: ExtensionAPI) { useHandler('click', zrUtil.curry(doEnter, 'click')); useHandler('mousemove', zrUtil.curry(doEnter, 'mousemove')); + // For example, dataZoom may update series layout while mousewheel, + // axisPointer and tooltip need to follow that updates, otherwise, + // highlighted items (by axisPointer) may have no chance to downplay. + useHandler('mousewheel', zrUtil.curry(doEnter, 'mousewheel')); // useHandler('mouseout', onLeave); useHandler('globalout', onLeave); @@ -134,7 +131,7 @@ function onLeave( } function doEnter( - currTrigger: 'click' | 'mousemove' | 'leave', + currTrigger: 'click' | 'mousemove' | 'mousewheel' | 'leave', record: Record, e: ZRElementEvent, dispatchAction: DispatchActionMethod diff --git a/src/component/tooltip/TooltipModel.ts b/src/component/tooltip/TooltipModel.ts index f48d64144f..a7123274cc 100644 --- a/src/component/tooltip/TooltipModel.ts +++ b/src/component/tooltip/TooltipModel.ts @@ -106,7 +106,7 @@ class TooltipModel extends ComponentModel { trigger: 'item', // 'click' | 'mousemove' | 'none' - triggerOn: 'mousemove|click', + triggerOn: 'mousemove|click|mousewheel', alwaysShowContent: false, diff --git a/src/util/types.ts b/src/util/types.ts index c885c13531..c08d5948e9 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -1586,8 +1586,9 @@ export interface CommonTooltipOption { /** * When to trigger + * NOTE: mousewheel may modify view by dataZoom. */ - triggerOn?: 'mousemove' | 'click' | 'none' | 'mousemove|click' + triggerOn?: 'mousemove' | 'click' | 'none' | 'mousewheel' | 'mousemove|click|mousewheel' /** * Whether to not hide popup content automatically */