Event "legendselectchanged" has a Bug in Version 6.5.3 and Above
BenJackGill opened this issue · 5 comments
Confirmation
- I can confirm this problem is not reproducible with ECharts itself.
How are you introducing Vue-ECharts into your project?
ES Module imports
Versions
vue-echarts-bad@0.0.0 /Users/BenJackGill/Dev/vue-echarts
├─┬ @vitejs/plugin-vue@4.5.0
│ └── vue@3.3.9 deduped
├── echarts@5.4.3
├─┬ vue-echarts@6.6.1
│ ├── echarts@5.4.3 deduped
│ ├─┬ vue-demi@0.13.11
│ │ └── vue@3.3.9 deduped
│ └── vue@3.3.9 deduped
└─┬ vue@3.3.9
└─┬ @vue/server-renderer@3.3.9
└── vue@3.3.9 deduped
Details
Hello, and thank you for creating and maintaining the vue-echarts package. It is a valuable tool in the Vue ecosystem. However, I have encountered a bug that I want to report.
Issue Description:
I am working on a project where I need to add rounded corners (borderRadius
) to the top stack of a stacked bar chart in vue-echarts. To achieve this, I must identify the top stack dynamically and apply the borderRadius
to the series.data.itemStyle.borderRadius
property. Additionally, I need to track which items in the Chart Legend are selected.
During this process, I found a bug in vue-echarts, starting from version 6.5.3, which affects how the graph displays. When I use the legendselectchanged
event to track the Chart Legend, clicking on a legend item does not remove the corresponding series from the chart as expected.
Here is an image illustrating the problem:
Bug Discovery and Testing:
After thorough testing, I discovered this bug first appeared in version 6.5.3. The previous version, 6.5.2, does not have this issue and works correctly. The problem persists in version 6.5.3 and later versions, including the latest 6.6.1.
Demonstration Repositories:
- Repository demonstrating the bug (using vue-echarts version 6.6.1): https://github.com/BenJackGill/vue-echarts-bad
- Repository showing correct functionality (using vue-echarts version 6.5.2): https://github.com/BenJackGill/vue-echarts-good
Reproduction
As you can see from the release log, v6.5.3 changed the behavior of the internal setOption
.
In your case you may need to apply :update-options="{ notMerge: false }"
to your chart component. Otherwise when you update the option upon legendselectchanged
, ECharts may consider that you are creating a new chart and the instance may lose its internal legend selection state.
Thank you. Your fix worked.
I have read over this part from the docs again:
When update-options is not specified, notMerge: false will be specified by default when the setOption method is called if the option object is modified directly and the reference remains unchanged; otherwise, if a new reference is bound to option, notMerge: true will be specified.
But I'm confused why I need to specify :update-options="{ notMerge: false }"
. In this case shouldn't notMerge: false
be the default setting?
The legendselectchanged
event is used to trigger a change in the option object. The computed reference remains unchanged. Therefore notMerge: false
should be used by default.
You are creating a new option object in the computed function:
So barChartOptions.value
will be a fresh object each time its dependencies change. The legendselectchanged
event is irrelevant here.
Ok thanks I understand now. Thank you for taking the time to help explain this to me :)
Posting this info here for my own benefit when I have a similar problem in the future.
The crux of the issue is that my chart data loads async. Because of this I thought using a Computed Ref instead of a plain Ref to build the options object would be a good idea. But the Computed Ref object had some properties that were reactive variables, and whenever those reactive properties changed the Computed Ref recomputation was creating a new object reference (news to me at the time!). Therefore we need to add :update-options="{ notMerge: false }"
to ensure the new object gets merged with the old object.
Another solution would be to refactor and go back to using a plain Ref with a static options object. Then use watchers and such to update the object properties in place (barChartOptions.value.series = newSeriesData
). In scenario we do not need :update-options="{ notMerge: false }"
because are not creating a new object reference each time. We are just changing the object properties in place, and therefore notMerge: false
will already be used by default.
For completeness sake I will post a couple version of the full SFC here, but a lot of the logic for rounding the stacked bar chart is not needed. That's just what I had with the initial issue before learning the real problem.
Here is my old problematic code from the original issue:
Note, this version can easily be fixed by adding update-options="{ notMerge: false }"
to the v-chart
.
<template>
<v-chart
class="echart-container"
autoresize
:option="barChartOptions"
@legendselectchanged="handleLegendSelectChanged"
/>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { BarChart } from "echarts/charts";
import { LegendComponent, GridComponent } from "echarts/components";
import VChart from "vue-echarts";
use([GridComponent, LegendComponent, BarChart, CanvasRenderer]);
// Create some fake async data
const asyncData = ref([]);
onMounted(async () => {
// Pause for fake async load
await new Promise((resolve) => setTimeout(resolve, 500));
// Load the data
asyncData.value = [
{
date: new Date("2023-11-22T17:00:00.000Z"),
appearances: 1,
missedOpportunities: 2,
},
{
date: new Date("2023-11-23T17:00:00.000Z"),
appearances: 2,
missedOpportunities: 1,
},
];
});
// Track series visibility
const seriesVisibility = ref({
"Missed Opportunities": true,
Appearances: true,
});
// Update series visibility when legend is toggled
const handleLegendSelectChanged = (legend) => {
Object.entries(legend.selected).forEach(([selectedKey, selectedValue]) => {
seriesVisibility.value[selectedKey] = selectedValue;
});
};
// Create computed options for the chart
const barChartOptions = computed(() => {
// Create base series (data for this is added later)
const baseSeries = [
{
name: "Appearances",
type: "bar",
color: "#FF0000",
stack: "ranks",
},
{
name: "Missed Opportunities",
type: "bar",
color: "#333333",
stack: "ranks",
},
];
// Function to get the top stacked series for each date
const getTopSeriesForEachDate = () => {
// Object to store top series name for each date
const topSeriesForEachDate = {};
asyncData.value.forEach((dataPoint) => {
let topSeriesName = "";
// Check which series is on top for this data point
if (
seriesVisibility.value["Missed Opportunities"] &&
dataPoint.missedOpportunities > 0
) {
topSeriesName = "Missed Opportunities";
} else if (
seriesVisibility.value["Appearances"] &&
dataPoint.appearances > 0
) {
topSeriesName = "Appearances";
}
// Store the top series name for this date
if (topSeriesName) {
topSeriesForEachDate[dataPoint.date.toDateString()] = topSeriesName;
}
});
return topSeriesForEachDate;
};
// Function to add border radius to the top stacked series
const getSeriesDataWithTopStackBorderRadius = (stackInfo) => {
// Iterate over base series and create a new series
const series = baseSeries.map((seriesItem) => {
// Iterate over asyncData and create a new array of series data
const seriesData = asyncData.value.map((dataPoint) => {
const dataPointDateString = dataPoint.date.toDateString();
const dataPointTopStackName = stackInfo[dataPointDateString];
const isTopStack = dataPointTopStackName === seriesItem.name;
// Return the data item with the border radius applied
return {
value: [
dataPoint.date,
seriesItem.name === "Appearances"
? dataPoint.appearances
: dataPoint.missedOpportunities,
],
itemStyle: {
borderRadius: isTopStack ? [20, 20, 0, 0] : [0, 0, 0, 0],
},
};
});
const seriesOption = {
...seriesItem,
data: seriesData,
};
return seriesOption;
});
return series;
};
// Get the new series data with the top stack border radius applied
const seriesWithTopStackBorderRadius = getSeriesDataWithTopStackBorderRadius(
getTopSeriesForEachDate()
);
// Return the options object
const options = {
xAxis: {
type: "time",
axisLabel: {
formatter: "{d} {MMM} {yy}",
},
minInterval: 3600 * 1000 * 24, // 1 day in milliseconds
},
yAxis: {
type: "value",
show: true,
minInterval: 1,
},
series: seriesWithTopStackBorderRadius,
legend: {
show: true,
},
};
return options;
});
</script>
<style scoped>
.echart-container {
height: 500px;
width: 500px;
}
</style>
Here is an alternate version that uses a plain Ref with a static object and watchers to update the object properties in place:
<template>
<v-chart
class="echart-container"
autoresize
:option="barChartOptions"
@legendselectchanged="handleLegendSelectChanged"
/>
</template>
<script setup>
import { onMounted, ref, watch } from "vue";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { BarChart } from "echarts/charts";
import { LegendComponent, GridComponent } from "echarts/components";
import VChart from "vue-echarts";
use([GridComponent, LegendComponent, BarChart, CanvasRenderer]);
// Create some fake async data
const asyncData = ref([]);
onMounted(async () => {
// Pause for fake async load
await new Promise((resolve) => setTimeout(resolve, 500));
// Load the async data
asyncData.value = [
{
date: new Date("2023-11-22T17:00:00.000Z"),
appearances: 1,
missedOpportunities: 2,
},
{
date: new Date("2023-11-23T17:00:00.000Z"),
appearances: 2,
missedOpportunities: 1,
},
];
// Update the chart options
const seriesWithTopStackBorderRadius = getSeriesDataWithTopStackBorderRadius(
getTopSeriesForEachDate()
);
barChartOptions.value.series = seriesWithTopStackBorderRadius;
});
// Create base series (data for each series will be added later)
const baseSeries = [
{
name: "Appearances",
type: "bar",
color: "#FF0000",
stack: "ranks",
},
{
name: "Missed Opportunities",
type: "bar",
color: "#333333",
stack: "ranks",
},
];
// Track series visibility
const seriesVisibility = ref({
"Missed Opportunities": true,
Appearances: true,
});
// Update series visibility when legend is toggled
const handleLegendSelectChanged = (legend) => {
Object.entries(legend.selected).forEach(([selectedKey, selectedValue]) => {
seriesVisibility.value[selectedKey] = selectedValue;
});
};
// Function to get the top stacked series for each date
const getTopSeriesForEachDate = () => {
// Object to store top series name for each date
const topSeriesForEachDate = {};
asyncData.value.forEach((dataPoint) => {
let topSeriesName = "";
// Check which series is on top for this data point
if (
seriesVisibility.value["Missed Opportunities"] &&
dataPoint.missedOpportunities > 0
) {
topSeriesName = "Missed Opportunities";
} else if (
seriesVisibility.value["Appearances"] &&
dataPoint.appearances > 0
) {
topSeriesName = "Appearances";
}
// Store the top series name for this date
if (topSeriesName) {
topSeriesForEachDate[dataPoint.date.toDateString()] = topSeriesName;
}
});
return topSeriesForEachDate;
};
// Function to add border radius to the top stacked series
const getSeriesDataWithTopStackBorderRadius = (stackInfo) => {
// Iterate over base series and create a new series
const series = baseSeries.map((seriesItem) => {
// Iterate over asyncData and create a new array of series data
const seriesData = asyncData.value.map((dataPoint) => {
const dataPointDateString = dataPoint.date.toDateString();
const dataPointTopStackName = stackInfo[dataPointDateString];
const isTopStack = dataPointTopStackName === seriesItem.name;
// Return the data item with the border radius applied
return {
value: [
dataPoint.date,
seriesItem.name === "Appearances"
? dataPoint.appearances
: dataPoint.missedOpportunities,
],
itemStyle: {
borderRadius: isTopStack ? [20, 20, 0, 0] : [0, 0, 0, 0],
},
};
});
const seriesOption = {
...seriesItem,
data: seriesData,
};
return seriesOption;
});
return series;
};
// Watch for changes to asyncData and update the chart option properties in place
watch(
seriesVisibility,
() => {
console.log("seriesVisibility changed");
const seriesWithTopStackBorderRadius =
getSeriesDataWithTopStackBorderRadius(getTopSeriesForEachDate());
barChartOptions.value.series = seriesWithTopStackBorderRadius;
},
{ deep: true }
);
// Create computed options for the chart
const barChartOptions = ref({
xAxis: {
type: "time",
axisLabel: {
formatter: "{d} {MMM} {yy}",
},
minInterval: 3600 * 1000 * 24, // 1 day in milliseconds
},
yAxis: {
type: "value",
show: true,
minInterval: 1,
},
series: [], // Initial series data is empty, and will be added later
legend: {
show: true,
},
});
</script>
<style scoped>
.echart-container {
height: 500px;
width: 500px;
}
</style>