JS Vue3 dynamic bar chart graph with real-time reactive data - vue.js

Goal
I'm using Vue3 with the Composition API. I want to stream real-time data into a chart / graph (60 fps, possibly multiple streams / multiple updating arrays). To simplify for this question, I want to create a bar chart that updates it bars reactively to data changes by a button.
Attempts
I tried various JavaScript chart libraries like PrimeVue (Chart.js under the hood), vue-chartjs,Vue-ECharts, plotly.js. However, I'm still struggling either getting it to work or getting smooth animation. I thought real-time plotting would be a more common case, but I have a hard time finding examples (or my Google-foo fails me).
PrimeVue attempt
My best progress is with PrimeVue. I got the reactive part to work, but the issue is that the whole graph animates from scratch at each data update.
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import PrimeVue from 'primevue/config';
import Chart from 'primevue/chart';
const app = createApp(App);
app.use(PrimeVue);
app.component('Chart', Chart);
app.mount("#app");
<script setup lang="ts">
import { reactive, ref } from 'vue'
const chartValues = reactive([0.3, 1])
const basicData = ref({
labels: ['January', 'February'],
datasets: [
{
label: 'My First dataset',
backgroundColor: '#42A5F5',
data: chartValues
},
{
label: 'My Second dataset',
backgroundColor: '#FFA726',
data: [0.4, 0.1]
}
]
});
const horizontalOptions = ref(
{
// animation: {
// duration: 0
// }
}
);
function increment() {
chartValues[0] += 0.1
if (chartValues[0] > 1) {
chartValues[0] = 0
}
}
</script>
<template>
<h1>PrimeVue chart 2</h1>
<button #click="increment">count is: {{ chartValues[0] }}</button>
<div>
<div class="card">
<h5>Horizontal</h5>
<Chart type="bar" :data="basicData" :options="horizontalOptions" />
</div>
</div>
</template>
I can prevent the whole graph animation by setting animation: {duration: 0}, but this does not look dynamic/fluid. There is no transition between values.
Questions
It would be helpful if you could:
(best) Share a code snippet / link to a demo (any chart/graph library is okay) that only updates bars in a bar chart for which the data has changed (or line point in a line chart) with a smooth transition (or anything related to a single array) using Vue3.
Recommend a good JS chart library for smooth real-time plot animation that works with Vue3's reactive() / ref().
Any other advice that would help me

It depends on what kind of changes you want to make.
If you only want to change existing data, then it's relatively easy. If you want to add additional data to the chart it gets quite a bit harder, but based on your example, you're looking to mutate the a specific data inside an array so I won't cover the later.
The problem appears to be that these libraries don't handle the reactive data. Whether you have a reactive in ref or just using reactive The data passed to chart.js loses it's reactivity. I haven't looked into any of them to see why, but seems like prime-vue, vue-chartjs, and #j-t-mcc/vue3-chartjs all lose reactivity. I suspect they might be watching reactivity at a higher level, and when arrays and objects are mixed within reactive, it doesn't work well
to get around it, you can can manually call the update of the chartjs component. To do that pass the component a ref and then call the update() method when you are making an update (or using a watch)
<script>
import { reactive, ref, watch } from "vue";
export default {
setup() {
const barGraph = ref(null); // define the $ref
const basicData = reactive({
labels: ["January", "February"],
datasets: [
{
label: "My First dataset",
backgroundColor: "#42A5F5",
data: [0.3, 1],
},
{
label: "My Second dataset",
backgroundColor: "#FFA726",
data: [0.4, 0.1],
},
],
});
const horizontalOptions = {
animation: {
duration: 400,
},
};
function increment() {
let val = basicData.datasets[0].data[0];
basicData.datasets[0].data[0] = ((val * 10 + 1) / 10) % 1;
// when the data changes and the barGraph $ref is not null
if (barGraph.value) {
// call update method
barGraph.value.chart.update();
}
}
return {
basicData,
horizontalOptions,
increment,
barGraph,
};
},
};
</script>
<template>
<button #click="increment">
count is: {{ basicData.datasets[0].data[0] }}
</button>
<div>
<div class="card">
<h5>Horizontal</h5>
<Chart
type="bar"
:data="basicData"
:options="horizontalOptions"
ref="barGraph"
/>
</div>
</div>
</template>

With the Vue-ApexCharts library and learning from Daniel's answer, I was able to get a reactive bar chart to work.
Install Vue-ApexCharts in Vue3:
npm install --save apexcharts
npm install --save vue3-apexcharts
main.ts
import { createApp } from 'vue'
import App from './App.vue'
import VueApexCharts from "vue3-apexcharts";
const app = createApp(App);
app.use(VueApexCharts);
app.mount("#app");
App.vue
<template>
<div id="app">
<button #click="increment">Increment bar 1</button>
<button #click="addSeries">Add data series</button>
<button #click="incrementExtra">Increment appended series data</button>
<VueApexCharts :options="chartData.options" :series="chartData.series"/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import VueApexCharts from 'vue3-apexcharts'
const seriesData1 = reactive([0.3, 1])
const seriesDataExtra = ref([0.1, 0.7])
const chartData = reactive({
series: [
{
name: 'My First series',
data: seriesData1
},
{
name: 'My Second series',
data: [0.4, 0.1]
}
],
options: {
chart: {
type: 'bar',
// https://apexcharts.com/docs/options/chart/animations/
animations: {
enabled: true,
easing: 'linear',
speed: 200,
animateGradually: {
enabled: false
},
dynamicAnimation: {
enabled: true,
speed: 150
}
}
},
xaxis: {
categories: ['January', 'February']
}
}
})
function increment() {
seriesData1[0] = ((seriesData1[0] * 10 + 1) / 10) % 1 // + 0.1
// chartData.series[0].data[0] = ((chartData.series[0].data[0] * 10 + 1) / 10) % 1
console.log(seriesData1)
}
function incrementExtra() {
seriesDataExtra.value = seriesDataExtra.value.map(element => ((element * 10 + 1) / 10) % 1)
console.log(seriesDataExtra)
}
function addSeries() {
console.log("Add extra series")
chartData.series.push({
name: 'My Next series',
data: seriesDataExtra
})
}
</script>
The above code can update a single bar's data, which triggers only an animation for that bar. This is done by:
Creating a reactive variable: const seriesData1 = reactive([0.3, 1])
With data: seriesData1 we assign this reactive array to the Chart
The first button triggers the function increment, which increments the first bar's data by 0.1: seriesData1[0] = ((seriesData1[0] * 10 + 1) / 10) % 1
Note that for step 3 we could have also directly updated the data in the Chart with chartData.series[0].data[0] = ((chartData.series[0].data[0] * 10 + 1) / 10) % 1 and this would give the same effect (this line of code also updates the variable seriesData1).
However, by updating the reactive variable seriesData1, we can separate data and the chart logic. You don't have to know how to assign data to the chart, but still get your reactive chart by just modifying the data.
As a bonus I also added a demonstration on how to:
Add an extra data stream / series to the plot with the function addSeries and
Reactively animate this new data by incrementing all values in a ref array using the function incrementExtra.
Modify the animation behavior so it updates all bars at the same time without a bouncy effect (default)
Demo video: https://i.imgur.com/e5a0y8Z.mp4

Related

VueJS template ref causes recursive updates

I'm trying to use the PrimeVue Chart component in my VueJS app, but to do that I need to reference the underlying chart, which requires a template reference. Unfortunately, as soon as I add that, I get "Maximum recursive updates exceeded." Here's a super-simple component that causes it:
<script setup>
import { ref } from "vue"
const pvChart = ref(null)
let basicData = {
datasets: [{
label: "Setpoints",
data: [{ x: -10, y: 0 },
{ x: 0, y: 10 },
{ x: 10, y: 5 },
{ x: 0.5, y: 5.5 }],
showLine: true,
},
]
}
</script>
<template>
<div>
<div class="card">
<pv-chart ref="pvChart" type="scatter" :data="basicData" />
</div>
</div>
</template>
I don't even try to update anything (AFAICT).
I tried to follow the example in this SO question, but it's written in a slightly different style, and I'm not sure I didn't miss something in the translation.
Update:
Changing the name of the ref from pvChart to primeChart fixes all the issues. I just grepped my whole codebase and there is no other pvChart in it anywhere. I don’t know why this is an issue.

Vue.js emit, pass array to child,map the array in child and emit event to parent, developer tool shows array is updated but the page not updated

I am passing the array drinks as props to Drinks.vue and map elements inside of Drinks.vue
When I click the button, it is supposed to emit the event to change the element to 'Sold Out'.
However, when debugging using console.log or Vue developer tool, it shows the drinks array is updated, but the page itself doesn't re-render or update...
Fruits works well. Do you know why Drinks.vue could not update drinks array?
App.vue
<template>
<div>
<Header :shop_name="shop_name" v-on:change_shop_name_event="change_shop_name($event)" />
<Fruits :stocks="fruits" v-on:sell_fruit_event="update_fruit_status($event)" />
<Drinks :stocks="drinks" v-on:sell_drink_event="update_drink_status($event)" />
<Footer :shop_name="shop_name" />
</div>
</template>
<script>
import Header from "./components/Header.vue";
import Fruits from "./components/Fruits.vue";
import Drinks from "./components/Drinks.vue";
import Footer from "./components/Footer.vue";
export default {
name: "App",
components: {
Header,
Fruits,
Drinks,
Footer,
},
data: function () {
return {
shop_name: "Zowie Grocery",
fruits: {
Peach: 10,
Apple: 10,
Blueberry: 10,
Strawberry: 10,
Mongo: 10,
Watermelon: 10,
},
drinks: ["beer", "orange juice", "milk shake", "tea", "coke"],
};
},
methods: {
change_shop_name: function (payload) {
this.shop_name = payload;
},
update_fruit_status: function (payload) {
this.fruits[payload] -= 1;
},
update_drink_status: function (index) {
console.log(index);
this.drinks[index] = "Sold Out";
},
},
};
</script>
<style>
</style>
Drinks.vue
<template>
<div>
<h2>This is the drinks we have in stock</h2>
<div v-for="(value,index) in stocks" :key="index">
<p>{{value}}</p>
<button #click="sell(index)">Sell</button>
</div>
</div>
</template>
<script>
export default {
name: "Drinks",
props: ["stocks"],
methods: {
sell: function (index) {
this.$emit("sell_drink_event", index);
},
},
};
</script>
can you try this instead of this.drinks[index] = "Sold Out" :
this.drinks = this.drinks.map((k,idx)=> idx === index ? 'Sold Out' : k)
Arrays are not reactive the way objects are in Vue.js. While just assigning a new value to an objects attribute just like that
someObject.x = 'some value'
it's not the same for an array and its elements. Just to clarify - yes, you can modify any index of an array but this won't trigger a re-render in Vue.js because of its reactivity system. If you modify an array by its index you've to take care about re-rendering the component yourself.
However, there's still hope. If you want Vue.js's reactivity system to take care about re-renders (even for Arrays) you've to use Vue.$set as stated in the docs.
So for your example it should be like this.
this.$set(this.drinks, index, 'Sold Out');
This will not only update the array but also toggle your expected re-render.

Delay the rendering of a component in vue

I've created a component in vue which wraps a vue-apexchart donut graph. As soon as the page loads and this component is loaded, the vue-apexchart animates and displays a small graph.
Now I would like to instantiate multiple of these components from a dataset side by side. Instead of the components to all load an animate at the same time, I would like a small rendering delay to give it an overall nice effect. Something like this would be nice:
<donut :items="series1"></donut>
<donut :items="series2" delay=1500></donut>
The vue-apexchart doesent support initialization delays, and as far as I can see there isn't any vue-specific official solution to delay the rendering of components.
I've tried to put a setTimeout in any of the component hooks to stall the initialization,
I´ve also tried to inject the all the graph DOM in the template element on a v-html tag in a setTimeout, but apexchart doesent notice this new dom content, and vue doesent notice the html bindings either.
I´ve created this fiddle which loads two instances of a graph:
https://jsfiddle.net/4f2zkq5c/7/
Any creative suggestions?
There are several ways you can do this, and it depends on whether you can actually modify the <animated-component> logic yourself:
1. Use VueJS's built-in <transition-group> to handle list rendering
VueJS comes with a very handy support for transitions that you can use to sequentially show your <animated-component>. You will need to use a custom animation library (like VelocityJS) and simply store the delay in the element's dataset, e.g. v-bind:data-delay="500". VueJS docs has a very good example on how to introduce staggered transitions for <transition-group>, and the example below is largely adapted from it.
You then use the beforeAppear and appear hooks to set the opacity of the individual children of the <transition-group>.
Vue.component('animated-component', {
template: '#animatedComponentTemplate',
props: {
data: {
required: true
}
}
});
new Vue({
el: '#app',
data: {
dataset: {
first: 'Hello world',
second: 'Foo bar',
third: 'Lorem ipsum'
}
},
methods: {
beforeAppear: function(el) {
el.style.opacity = 0;
},
appear: function(el, done) {
var delay = +el.dataset.delay;
setTimeout(function() {
Velocity(
el, {
opacity: 1
}, {
complete: done
}
)
}, delay)
}
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<transition-group name="fade" v-on:before-appear="beforeAppear" v-on:appear="appear">
<animated-component v-bind:data="dataset.first" v-bind:key="0"> </animated-component>
<animated-component v-bind:data="dataset.second" v-bind:key="1" v-bind:data-delay="500"> </animated-component>
<animated-component v-bind:data="dataset.third" v-bind:key="2" v-bind:data-delay="1000"> </animated-component>
</transition-group>
</div>
<script type="text/x-template" id="animatedComponentTemplate">
<div>
<h1>Animated Component</h1>
{{ data }}
</div>
</script>
2. Let <animated-component> handle its own rendering
In this example, you simply pass the a number to the delay property (remember to use v-bind:delay="<number>" so that you pass a number and not a string). Then, in the <animated-component>'s mounted lifecycle hook, you use a timer to toggle the visibility of the component itself.
The technique on how you want to show the initially hidden component is up to you, but here I simply apply an initial opacity of 0 and then transition it after a setTimeout.
Vue.component('animated-component', {
template: '#animatedComponentTemplate',
props: {
data: {
required: true
},
delay: {
type: Number,
default: 0
}
},
data: function() {
return {
isVisible: false
};
},
computed: {
styleObject: function() {
return {
opacity: this.isVisible ? 1 : 0
};
}
},
mounted: function() {
var that = this;
window.setTimeout(function() {
that.isVisible = true;
}, that.delay);
}
});
new Vue({
el: '#app',
data: {
dataset: {
first: 'Hello world',
second: 'Foo bar',
third: 'Lorem ipsum'
}
}
});
.animated-component {
transition: opacity 0.25s ease-in-out;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<animated-component v-bind:data="dataset.first"> </animated-component>
<animated-component v-bind:data="dataset.second" v-bind:delay="500"> </animated-component>
<animated-component v-bind:data="dataset.third" v-bind:delay="1000"> </animated-component>
</div>
<script type="text/x-template" id="animatedComponentTemplate">
<div class="animated-component" v-bind:style="styleObject">
<h1>Animated Component, delay: {{ delay }}</h1>
{{ data }}
</div>
</script>
If you have the possibility to reformat your data, you can build an array of series objects, add a show: true/false property and iterate it:
//template
<div v-for="serie in series">
<donut :items="serie.data" v-if="serie.show"></donut>
</div>
//script
data: function() {
return {
series: [
{ data: [44, 55, 41, 17, 15], show: false },
{ data: [10, 20, 30], show: false },
]
}
}
Now you can create a setTimeout function which will change the serie.show to true by incrementing the delay based on the serie index.
Then add the function on the mounted hook:
methods: {
delayedShow (serie, idx) {
let delay = 1500 * idx
setTimeout(() => {
serie.show = true
}, delay)
}
},
mounted () {
this.series.forEach((serie, idx) => {
this.delayedShow(serie, idx)
})
}
Live example
Faced the same problem with ApexCharts Pie Charts being redrawn rapidly in sequence due to data being pulled from a pinia store mutating too quickly for the chart to keep up, leading to ugly errors in the console.
I resolved the issue by using a boolean ref in a v-if="showChart" on the component and then using a setTimeout to trigger a delayed drawing of the chart:
import { ref } from "vue";
import useStore from "#/store/myChartStore";
const store = useStore();
const showChart = ref(false);
store.$subscribe((mutation, state) =>{
showChart.value = false;
setTimeout(()=> {
showChart.value = true;
}
, 100);
});
If you're not using a store, you may find another way to watch the initial availability of the chart data and then delay the rendering using that same approach.

Using paperjs on a vuejs project based on SFC

I have a vuejs project based on single file components. I want to add a canvas and dinamically draw things with paperjs, based on my component data. What is the proper way to do it?
Self response. A working SFC.
<template>
<canvas resize id="main-canvas">{{circle_diameter}}</canvas>
</template>
<script>
import paper from 'paper'
export default {
data: () => ({ x: 20, y: 20 }),
props: ['circle_diameter'],
methods: {
updateDrawing() {
paper.setup(document.getElementById('main-canvas'))
// Now, draw your things based on component state.
const point = new paper.Point(this.x, this.y)
const circle = new paper.Path.Circle(point, this.circle_diameter/2)
circle.fillColor = 'grey'
circle.strokeColor = 'black'
},
},
updated() {
this.updateDrawing()
},
}
</script>
EDIT
Since paperjs will render outside vue scope, drawing is not reactive until you place {{circle_diameter}} into the canvas html tags. This way, you force Vue to call update() each time a prop changes.

Pulling in several static properties into Vue.js

I am very new to Vue.js and we are working on adding in Vue.js into an existing project piece by piece. I'm working on rewriting the product slider in Vue. It is currently using the jquery slick slider. So in the current/old code in the html this js function is being called:
function productDetailsRecommendations(compositeNumbers) {
var params = {
compositeNumbers: compositeNumbers,
strategy: 'pp12',
backupStrategy: 'popular',
divId: 'recommendedProductsHorizontal',
isVertical: false,
isHideHeaderText: false,
headerText: 'Guests Who Viewed This Item Also Viewed These',
backupHeaderText: 'Popular Products',
itemsPerPage: 5,
itemDisplayLimit: 10,
numberOfItems: 15,
responseMap: null
};
createSlider(params);
}
Now I am using vue-carousel to recreate the slider. So I replaced that call with my own copied function: productDetailsRecommendationsVue.
Now I have created a ProductRecommendationsSlider.vue as the slider component. And I have a index.js as the entry point where the slider gets initialized.
Now my boss told me I need to put the productDetailsRecommendationsVue function into index.js.
// index.js
import Vue from 'vue';
import axios from 'axios';
import VueCarousel from 'vue-carousel';
import Slider from '/components/slider/ProductRecommendationsSlider'
Vue.use(VueCarousel);
window.productDetailsRecommendationsVue=function(compositeNumbers) {
var params = {
compositeNumbers: compositeNumbers,
strategy: 'pp12',
backupStrategy: 'popular',
divId: 'recommendedProductsHorizontal',
isVertical: false,
isHideHeaderText: false,
headerText: 'Guests Who Viewed This Item Also Viewed These',
backupHeaderText: 'Popular Products',
itemsPerPage: 5,
itemDisplayLimit: 10,
numberOfItems: 15,
responseMap: null
};
};
/* eslint-disable no-new */
new Vue({
el: '#itemDetailPage #recommendedProductsHorizontal .imageSlider',
components: {
Slider,
'carousel': VueCarousel.Carousel,
'slide': VueCarousel.Slide
},
template: '<product-slider></product-slider>'
});
But my main question is how do I get those parameters into the component?
They are needed in one of the functions in ProductRecommendationsSlider.vue. My boss said I was on the right track with placing the js function there in the index.js. All the tutorials I've found online talk about building a project from scratch. Tying Vue into an existing project is much more difficult IMO.
Since you're using single file components (*.vue within a Vue CLI generated project), your project already has modularization support, so you wouldn't need to attach properties/functions to the window object. Instead, you could encapsulate your static properties/functions within the component file itself:
// ProductRecommendationsSlider.vue
<script>
function productDetailsRecommendations() {
return { /*...*/ }
}
export default {
data() {
params: {}
},
methods: {
loadParams() {
this.params = productDetailsRecommendations();
}
}
}
</script>
or in separate files that you could import into your component:
// ProductRecommendationsSlider.vue
<script>
import { productDetailsRecommendations } from '#/utils';
export default {
data() {
params: {}
},
methods: {
loadParams() {
this.params = productDetailsRecommendations();
}
}
}
</script>
// <root>/src/utils.js
export function productDetailsRecommendations() {
return { /*...*/ }
}
Then, you could bind those parameters to your vue-carousel properties. Note only some of the parameters in your example appear to be supported by vue-carousel (unsupported marked by n/a):
"strategy": "pp12", // n/a
"backupStrategy": "popular", // n/a
"divId": "recommendedProductsHorizontal", // ID of container div
"isVertical": false, // n/a
"isHideHeaderText": false, // true = hide `headerText` h3; false = show it
"headerText": "Guests Who Viewed This Item Also Viewed These", // h3 text content (isHideHeaderText: true)
"backupHeaderText": "Popular Products", // h3 text content (isHideHeaderText: false)
"itemsPerPage": 5, // vue-carousel perPage
"itemDisplayLimit": 10, // n/a
"numberOfItems": 15, // vue-carousel item count
"responseMap": null // n/a
Example data bindings:
<template>
<div class="product-slider" :id="params.recommendedProductsHorizontal">
<h3 v-if="!params.isHideHeaderText">{{params.headerText}}</h3>
<carousel :perPage="params.itemsPerPage">
<slide v-for="i in params.numberOfItems" :key="i">
<span class="label">{{i}}</span>
</slide>
</carousel>
<section>
<button #click="loadParams">Load params</button>
<pre>params: {{params}}</pre>
</section>
</div>
</template>
demo
You can assign window.productDetailsRecommendationVue in vue data or computed properties
1) Change window.productDetailsRecommendationsVue from a function to
window.productDetailsRecommendationsVue = {
//compositeNumbers: "I have no idea where this comes from but it could be passed separately",
strategy: "pp12",
backupStrategy: "popular",
divId: "recommendedProductsHorizontal",
isVertical: false,
isHideHeaderText: false,
headerText: "Guests Who Viewed This Item Also Viewed These",
backupHeaderText: "Popular Products",
itemsPerPage: 5,
itemDisplayLimit: 10,
numberOfItems: 15,
responseMap: null
};
2) inside of your vue instance of index.js assign window.productDetailsRecommendtionsVue to a data property:
new Vue({
el: '#itemDetailPage #recommendedProductsHorizontal .imageSlider',
components: {
Slider,
'carousel': VueCarousel.Carousel,
'slide': VueCarousel.Slide
},
data: {
oldSliderData: window.productDetailsRecommendationsVue
}
template: '<product-slider></product-slider>'
});
It is now accessible to components using the standard prop process. I'm not sure where is coming from b/c I don't see it imported.