ecomfe / vue-echarts

Vue.js component for Apache ECharts™.
https://vue-echarts.dev
MIT License
9.43k stars 1.48k forks source link

Event "legendselectchanged" has a Bug in Version 6.5.3 and Above #748

Closed BenJackGill closed 7 months ago

BenJackGill commented 7 months ago

Confirmation

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

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:

Reproduction

https://github.com/BenJackGill/vue-echarts-bad

Justineo commented 7 months ago

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.

BenJackGill commented 7 months ago

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.

Justineo commented 7 months ago

You are creating a new option object in the computed function:

https://github.com/BenJackGill/vue-echarts-bad/blob/6151073f30ffc0a4010492de998bfbf59dbafed9/src/App.vue#L133-L151

So barChartOptions.value will be a fresh object each time its dependencies change. The legendselectchanged event is irrelevant here.

BenJackGill commented 7 months ago

Ok thanks I understand now. Thank you for taking the time to help explain this to me :)

BenJackGill commented 7 months ago

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>