Click here to Skip to main content
14,971,409 members
Articles / Web Development / Vue.js
Article
Posted 4 Dec 2020

Stats

3.1K views
189 downloads
3 bookmarked

Vue Samples Graph - How to Create a Graph Component using the Vue Framework

Rate me:
Please Sign up or sign in to vote.
5.00/5 (5 votes)
4 Dec 2020MIT19 min read
Create a component that presents samples graph using Vue Framework
This article shows how we can create a component that presents a samples graph using Vue.
Article header

Table of Contents

Introduction

A good way of learning a new framework is developing something using it. In my case, I had an application that was written using AngularJS and, I wanted to migrate it to a more modern framework. After some research, I found Vue as the most compatible solution for what I needed and, wanted to give it a try. To be more motivated, I decided to develop something that can be useful (and not just a learning project that will be thrown away).

One of the abilities of that migrated application is to measure some samples (of things that are going on in the system) and update some performance counters (that can be viewed using the Windows Performance Monitor). I thought that it can be good if we'll can see graphs of the taken samples also in the application's UI. So, I had an idea to develop a component for presenting samples graph. Since when I started this component's development the latest Vue version was 2.6.10, this is the version that is used for it.

Background

In this article, we create a front-end component for presenting samples graphs using the Vue framework. This article assumes a basic familiarity with HTML, CSS and, JavaScript. Since we are dealing with a front-end solution, a basic familiarity with modern front-end development (especially using Vue) is recommended too. Anyway, I tried to provide a link to a detailed explanation for any concept when it is first mentioned.

How It Works

Drawing a Graph

Canvas vs svg

The main part of our graph is the graph's drawing. HTML5 gives us 2 methods for showing drawings: Canvas and SVG. While SVG drawings are more vectorial and more responsive, Canvas drawings results with better performance.

In our graph implementation we use the both of the methods. We use SVG as the default method and, give the option for changing it to the Canvas method (for cases of a heavy drawing of a big data).

For that purpose, we create 2 internal components (one for each method):

JavaScript
const canvasDrawingComponent = {
    props: ['values', 'range', 'min', 'max', 'settings', 'highlighted_index'],
	template: `<canvas class="sz-graph-canvas"></canvas>`
};

const svgDrawingComponent = {
    props: ['values', 'range', 'min', 'max', 'settings', 'highlighted_index'],
	template: `<div class="sz-graph-svg"><svg></svg></div>`
};

and, a component for presenting the drawing according to the selected method:

JavaScript
const drawingComponent = {
    props: ['values', 'range', 'range_offset', 'min', 'max', 'settings', 'highlighted_index'],
    components: {
        'sz-graph-canvas': canvasDrawingComponent,
        'sz-graph-svg': svgDrawingComponent
    },
    template: 
        `<div class="sz-graph-drawing">
            <sz-graph-canvas v-if="settings.drawing.drawUsingCanvas" 
                :values="values" :range="range" :min="min" :max="max" :settings="settings" 
                :highlighted_index="highlighted_index"></sz-graph-canvas>
            <sz-graph-svg v-else 
                :values="values" :range="range" :min="min" :max="max" :settings="settings" 
                :highlighted_index="highlighted_index"></sz-graph-svg>
         </div>`
};

Canvas Drawing

In our graph drawing we have a graph for each values set and a grid lines (for indicating the scale values). A values' graph can contain: a line (connecting the values), a fill between the line and the graph's bottom and, circles for emphasizing the actual values.

For drawing our graph using a canvas element, we add a drawGraph function to our canvasDrawingComponent component:

JavaScript
methods: {
    drawGraph() {
        // Get the root ('canvas') element.
        const c = this.$el;

        // Set the canvas drawing area dimensions to be equal 
        // to the canvas element dimensions.
        c.width = c.offsetWidth;
        c.height = c.offsetHeight;

        // Get the canvas drawing context and clear it.
        const w = c.width, h = c.height;
        const ctx = c.getContext("2d");
        ctx.clearRect(0, 0, w, h);
        ctx.setLineDash([]);

        // Draw grid lines.
        if (this.settings.drawing.showGridLines) {
            this.drawGridLines(ctx, w, h);
        }

        // Draw graph lines.
        for (let valuesInx = 0; valuesInx < this.values.length; valuesInx++) {
            if (this.settings.drawing.graph.visibility[valuesInx]) {
                this.drawGraphLine(ctx, w, h, this.values[valuesInx], valuesInx);
            }
        }
    },
    drawGridLines(ctx, w, h) {
        const scaleSettings = this.settings.scale;

        ctx.lineWidth = 1;
        ctx.strokeStyle = this.settings.drawing.gridLinesColor;
        ctx.beginPath();
        const gridGap = h / 16;

        // Draw horizontal grid lines according to the scale settings.
        for (let i = 1; i < 16; i++) {
            if (h > scaleSettings.minimalValuesGap * 16 ||
                (i % 2 == 0 && h > scaleSettings.minimalValuesGap * 8) ||
                (i % 4 == 0 && h > scaleSettings.minimalValuesGap * 4) ||
                (i % 8 == 0 && h > scaleSettings.minimalValuesGap * 2)) {
                ctx.moveTo(0, i * gridGap);
                ctx.lineTo(w, i * gridGap);
            }
        }

        ctx.stroke();
        ctx.closePath();
    },
    drawGraphLine(ctx, w, h, vals, valuesInx) {
        const graphSettings = this.settings.drawing.graph;

        const valsCount = vals.length;

        if (valsCount > 1) {
            const minVal = this.min, maxVal = this.max;
            const valsRange = maxVal - minVal;
            const widthUnit = w / this.range, heightUnit = h / valsRange;

            ctx.lineWidth = valuesInx == this.highlighted_index ? 3 : 1;
            ctx.strokeStyle = graphSettings.colors[valuesInx];

            // Draw graph fill.
            if (graphSettings.showFill) {
                ctx.fillStyle = graphSettings.fillColors[valuesInx];

                ctx.beginPath();
                ctx.moveTo(0, h - (vals[0] - minVal) * heightUnit);
                for (let i = 1; i < valsCount; i++) {
                    ctx.lineTo(i * widthUnit, h - (vals[i] - minVal) * heightUnit);
                }
                ctx.lineTo((valsCount - 1) * widthUnit, h);
                ctx.lineTo(0, h);
                ctx.lineTo(0, h - (vals[0] - minVal) * heightUnit);
                ctx.fill();
                ctx.closePath();
            }

            // Draw graph line.
            if (graphSettings.showLines) {
                ctx.beginPath();
                ctx.moveTo(0, h - (vals[0] - minVal) * heightUnit);
                for (let i = 1; i < valsCount; i++) {
                    ctx.lineTo(i * widthUnit, h - (vals[i] - minVal) * heightUnit);
                }
                ctx.stroke();
                ctx.closePath();
            }

            // Draw graph circles.
            if (graphSettings.showCircles) {
                ctx.fillStyle = graphSettings.colors[valuesInx];
                ctx.beginPath();
                ctx.arc(0, h - (vals[0] - minVal) * heightUnit, 3, 0, 2 * Math.PI);
                ctx.stroke();
                ctx.fill();
                ctx.closePath();
                for (let i = 1; i < valsCount; i++) {
                    ctx.beginPath();
                    ctx.arc(i * widthUnit, 
                            h - (vals[i] - minVal) * heightUnit, 3, 0, 2 * Math.PI);
                    ctx.stroke();
                    ctx.fill();
                    ctx.closePath();
                }
            }
        }
    }
}

and call it when the component is mounted and when the graph's values are changed:

JavaScript
mounted() {
    this.$watch('values',
        () => {
            this.drawGraph();
        });

    this.drawGraph();
}

In the drawGraph function, we got the canvas HTML element and apply the drawing using its context. Since every component must have a single root element, we could achieve that by using a canvas as the root template element and getting it using the $el property.

In the drawGridLines function, we draw the graph's grid lines according to the graph's scale (that will be described later in this article).

In the drawGraphLine function, we draw a graph for a given values set according to the graph's display options. These options can be controlled using the graph's settings as will be described later in this article.

Svg Drawing

Using Canvas, we draw our graph by accessing (using JavaScript code) the actual HTML element and using its context's functions. Using SVG, since SVG drawings are HTML elements, we can draw the graph by adding some computed properties and bind them to the template's elements:

JavaScript
data() {
    return {
        height: 0
    };
},
template: 
    `<div class="sz-graph-svg"><svg>
        <g v-if="settings.drawing.showGridLines" 
            :stroke="settings.drawing.gridLinesColor" stroke-width="1"  >
            <line v-for="lp in gridLines" x1="0" :y1="lp.y" x2="100%" :y2="lp.y" />
        </g>
        <g v-for="(n, valInx) in values.length" v-if="settings.drawing.graph.visibility[valInx]" 
            :stroke="settings.drawing.graph.colors[valInx]" 
            :stroke-width="valInx == highlighted_index ? 3 : 1" stroke-linecap="round" >
            <svg v-if="settings.drawing.graph.showFill" 
                viewBox="0 0 100 100" preserveAspectRatio="none" 
                style="width: 100%; height:100%">
                <path :d="fillPathes[valInx]" 
                    :fill="settings.drawing.graph.fillColors[valInx]" stroke-width="0" />
            </svg>
            <line v-if="settings.drawing.graph.showLines" v-for="lp in graphLines[valInx]" 
                :x1="lp.x1" :y1="lp.y1" :x2="lp.x2" :y2="lp.y2" />
            <circle v-if="settings.drawing.graph.showCircles" cx="0" 
                :cy="firstGraphValues[valInx]" 
                r=3 :fill="settings.drawing.graph.colors[valInx]" />
            <circle v-if="settings.drawing.graph.showCircles" 
                    v-for="lp in graphLines[valInx]" 
                :cx="lp.x2" :cy="lp.y2" r=3 :fill="settings.drawing.graph.colors[valInx]" />
        </g>
     </svg></div>`,
mounted() {
    this.height = this.$el.offsetHeight;
},
computed: {
    gridLines() {
        let res = [];

        const h = this.height;

        const scaleSettings = this.settings.scale;
        const gridGap = 100 / 16;

        // Get horizontal grid lines coordinates according to the scale settings.
        for (let i = 1; i < 16; i++) {
            if (h > scaleSettings.minimalValuesGap * 16 ||
                (i % 2 == 0 && h > scaleSettings.minimalValuesGap * 8) ||
                (i % 4 == 0 && h > scaleSettings.minimalValuesGap * 4) ||
                (i % 8 == 0 && h > scaleSettings.minimalValuesGap * 2)) {

                res.push({
                    y: `${i * gridGap}%`
                });
            }
        }

        return res;
    },
    fillPathes() {
        let res = [];

        if (this.range > 1) {
            const minVal = this.min, maxVal = this.max;
            const valsRange = maxVal - minVal;
            const widthUnit = 100 / this.range, heightUnit = 100 / valsRange;

            // Get the path's data for the graphs' fill.
            res = this.values.map(vals => {
                let pathStr = 'M';

                for (let valInx = 0; valInx < vals.length; valInx++) {
                    pathStr = `${pathStr}${valInx * widthUnit},
                    ${100 - (vals[valInx] - minVal) * heightUnit} `;
                }

                pathStr = `${pathStr}${(vals.length - 1) * widthUnit},100 0,100z`;

                return pathStr;
            });
        }

        return res;
    },
    graphLines() {
        let res = [];

        if (this.range > 1) {
            const minVal = this.min, maxVal = this.max;
            const valsRange = maxVal - minVal;
            const widthUnit = 100 / this.range, heightUnit = 100 / valsRange;

            res = this.values.map(vals => {
                const linesPoints = [];

                // Get graph line coordinates.
                for (let valInx = 1; valInx < vals.length; valInx++) {
                    linesPoints.push({
                        x1: `${(valInx - 1) * widthUnit}%`,
                        y1: `${100 - (vals[valInx - 1] - minVal) * heightUnit}%`,
                        x2: `${valInx * widthUnit}%`,
                        y2: `${100 - (vals[valInx] - minVal) * heightUnit}%`
                    });
                }

                return linesPoints;
            });
        }

        return res;
    },
    firstGraphValues() {
        const minVal = this.min, maxVal = this.max;
        const valsRange = maxVal - minVal;
        const heightUnit = 100 / valsRange;

        return this.values.map(vals => vals.length > 0 ?
            `${100 - (vals[0] - minVal) * heightUnit}%` : '0');
    }
}

In that way, instead of watching on values changes and call a function that redraw the entire graph for each change (as we do using the canvas element), we just define computed properties for the needed data and let Vue to do the needed re-rendering work.

Graph Scale

In addition to the graph's drawing, we may want to show a scale for the presented graph's values. For that purpose, we create another internal component:

JavaScript
const scaleComponent = {
    props: ['min', 'max', 'settings']
};

In that component, we add a computed property for the presented scale values:

JavaScript
data() {
    return { elHeight: 0 };
},
mounted() {
    this.elHeight = this.$el.offsetHeight;
},
computed: {
    values() {
        const scaleSettings = this.settings.scale;
        const max = parseFloat(this.max);
        const min = parseFloat(this.min);
        const secondPart = (max - min) / 2;
        const thirdPart = secondPart / 2;
        const fourthPart = thirdPart / 2;
        const fifthPart = fourthPart / 2;

        const elHeight = this.elHeight && this.elHeight > 0 ? this.elHeight : 200;

        return {
            first: [min, max],
            second: [secondPart + min],
            third: [thirdPart + min, max - thirdPart],
            fourth: [fourthPart + min, fourthPart * 3 + min, 
                     fourthPart * 5 + min, max - fourthPart],
            fifth: [fifthPart + min, fifthPart * 3 + min, 
                    fifthPart * 5 + min, fifthPart * 7 + min,
            fifthPart * 9 + min, fifthPart * 11 + min, 
                                 fifthPart * 13 + min, max - fifthPart],
            showSecond: elHeight > scaleSettings.minimalValuesGap * 2,
            showThird: elHeight > scaleSettings.minimalValuesGap * 4,
            showFourth: elHeight > scaleSettings.minimalValuesGap * 8,
            showFifth: elHeight > scaleSettings.minimalValuesGap * 16
        };
    }
}

and, a template for presenting these values:

JavaScript
template: 
    `<div class="sz-graph-scale">
        <div class="sz-fifth" v-if="values.showFifth">
            <div class="sz-scale-val" v-for="v in values.fifth">{{ v }}</div>
        </div>
        <div class="sz-fourth" v-if="values.showFourth">
            <div class="sz-scale-val" v-for="v in values.fourth">{{ v }}</div>
        </div>
        <div class="sz-third" v-if="values.showThird">
            <div class="sz-scale-val" v-for="v in values.third">{{ v }}</div>
        </div>
        <div class="sz-second" v-if="values.showSecond">
            <div class="sz-scale-val" v-for="v in values.second">{{ v }}</div>
        </div>
        <div class="sz-first">
            <div class="sz-scale-val" v-for="v in values.first">{{ v }}</div>
        </div>
    </div>`

For presenting the scale values in their right positions in the scale, we use a one cell grid layout (all the elements in the same cell) for the scale element and, a flexbox layout for each values set. In order to spread the scale from the top of graph to its bottom, we use a space-between alignment for the major scale values and a space-around alignment for the rest of the values:

CSS
.sz-graph-scale {
    grid-area: scale;
    position: relative;
    margin: 0;
    display: grid;
    grid-template-columns: 100%;
    grid-template-rows: 100%;
    grid-template-areas: "all";
    padding-right: 12px;
    border-right: solid 0.1em #333;
}

.sz-graph-scale .sz-first, .sz-graph-scale .sz-second, 
.sz-graph-scale .sz-third, .sz-graph-scale .sz-fourth,
.sz-graph-scale .sz-fifth {
    grid-area: all;
    display: flex;
    position: relative;
    flex-direction: column-reverse;
    justify-content: space-around;
}

.sz-graph-scale .sz-first {
    justify-content: space-between;
    margin-top: -0.5em;
    margin-bottom: -0.5em;
}

For presenting a tiny scale lines (indicate the values' position in the scale) for the scale's values, we add some style for the before pseudo-elements of the scale's values:

CSS
.sz-scale-val::before {
    position: absolute;
    content: "";
    height: 0.05em;
    background: #333;
    right: -12px;
    bottom: 0.58em;
}

.sz-first .sz-scale-val::before {
    width: 10px;
}

.sz-second .sz-scale-val::before {
    width: 8px;
}

.sz-third .sz-scale-val::before {
    width: 6px;
}

.sz-fourth .sz-scale-val::before {
    width: 4px;
}

.sz-fifth .sz-scale-val::before {
    width: 2px;
}

Graph Range

Controlling Presented Graph Range

Another thing that we may want to see in our graph is an indication about the current presented range. In addition to seeing the current range, we may want also to pause the graph's updates and, scroll to another wanted range. For that purpose, we create another internal component:

JavaScript
const rangeComponent = {
    props: ['min', 'max', 'paused', 'back_offset', 'settings'],
    template: 
        `<div class="sz-graph-range">
            <div class="sz-min-val">{{ minStr }}</div>
            <input type="range" v-show="rangeVisible" 
             min="0" :max="rangeMax" v-model="rangeVal">
            </input>
            <button v-if="settings.showPauseButton" @click="onPauseClicked">
				<svg v-if="this.paused" viewBox="0 0 100 100" 
				preserveAspectRatio="none">
					<polygon points="20,10 85,50 20,90" /> 
				</svg>
				<svg v-else>
					<rect x="10%" y="5%" width="30%" 
					height="90%" rx="10%" ry="10%" />
					<rect x="60%" y="5%" width="30%" 
					height="90%" rx="10%" ry="10%" />
				</svg>
			</button>
            <div class="sz-max-val">{{ maxStr }}</div>
        </div>`,
    data() {
        return {
            rangeVal: 0
        };
    },
    computed: {
        rangeMax() {
            let ret = this.min + this.back_offset;

            // If we aren't paused, the back-offset is zero. 
            // So, set the range value to the range maximum.
            if (!this.paused) {
                this.rangeVal = ret;
            }

            return ret;
        },
        rangeVisible() {
            return this.settings.hasGraphRange && this.paused && this.rangeMax > 0;
        }
    }
};

In this component, we have the values of the range's boundaries, a button for pausing and resuming and, a range input for scrolling to the wanted range (when the graph is paused). These elements are arranged horizontally using a grid layout as follows:

CSS
.sz-graph-range {
    grid-area: range;
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr auto auto;
    grid-template-areas: "min range button max";
    border-top: solid 0.1em #333;
    min-height: 0.5em;
}

.sz-graph-range .sz-min-val {
    grid-area: min;
}

.sz-graph-range .sz-max-val {
    grid-area: max;
}

.sz-graph-range input[type="range"] {
    grid-area: range;
}

.sz-graph-range button {
	position: relative;
    grid-area: button;
    margin-top: 0.25em;
    margin-bottom: 0.25em;
	width: 2em;
}

In the pause/resume button, we have a resume graphic (resume image) that is shown when the graph is paused and a pause graphic (pause image) that is shown otherwise. For notifying about the paused state changes, we emit a paused-changed event for each click:

JavaScript
methods: {
    onPauseClicked() {
        this.$emit('paused-changed', !this.paused);
    }
}

Giving Meaningful Description to the Range

For now, we have a general solution for presenting range values, that can present minimal and maximal indexes of presented range. But, sometimes we may want a more meaningful description to the presented values' range. In our solution, we support two types of samples descriptions: numbers range and time range. For each descriptor, we can define a starting value and a constant step size. For that purpose, we add another properties set to the graph's settings:

JavaScript
samplesDescriptors: {
    type: 'number', // One of: ['number', 'time'].
    startingValue: 0,
    stepSize: 1,
    format: '',
    digitsAfterDecimalPoint: 0
}

For calculating the actual range values, we create a mixin with the needed operations:

JavaScript
const rangeOperationsMixin = {
    methods: {
        formatDate(date, format) {
            const twoDigitsStr = num => num > 9 ? `${num}` : `0${num}`;
            const threeDigitsStr = num => num > 99 ? `${num}` : `0${twoDigitsStr(num)}`;
            const monthsNames = ['January', 'February', 
            'March', 'April', 'May', 'June',
                'July', 'August', 'September', 
                'October', 'November', 'December'];
            const daysNames = ['Sunday', 'Monday', 'Tuesday', 
                               'Wednesday', 'Thursday', 'Friday', 'Saturday'];

            const year = date.getFullYear();
            const month = date.getMonth();
            const dayInMonth = date.getDate();
            const dayInWeek = date.getDay();
            const hours = date.getHours();
            const minutes = date.getMinutes();
            const seconds = date.getSeconds();
            const milliseconds = date.getMilliseconds();

            let res = format;
            res = res.replace('YYYY', `${year}`);
            res = res.replace('YY', twoDigitsStr(year % 100));
            res = res.replace('MMMM', monthsNames[month]);
            res = res.replace('MMM', monthsNames[month].substr(0, 3));
            res = res.replace('MM', twoDigitsStr(month + 1));
            res = res.replace('DDDD', daysNames[dayInWeek]);
            res = res.replace('DDD', daysNames[dayInWeek].substr(0, 3));
            res = res.replace('DD', daysNames[dayInWeek].substr(0, 2));
            res = res.replace('dd', twoDigitsStr(dayInMonth));
            res = res.replace('HH', twoDigitsStr(hours));
            res = res.replace('mm', twoDigitsStr(minutes));
            res = res.replace('sss', threeDigitsStr(milliseconds));
            res = res.replace('ss', twoDigitsStr(seconds));

            return res;
        },
        addMillisecondsToDate(date, milliseconds) {
            return new Date(date.getTime() + milliseconds);
        },
        getSampleDescriptorStr(value, samplesDescriptors) {
            if (samplesDescriptors.type == 'time') {
                return this.formatDate(
                    this.addMillisecondsToDate(
                        samplesDescriptors.startingValue, 
                        value * samplesDescriptors.stepSize), samplesDescriptors.format);
            }
            else {
                return (samplesDescriptors.startingValue + 
                        value * samplesDescriptors.stepSize).toFixed
                        (samplesDescriptors.digitsAfterDecimalPoint);
            }
        }
    }
};

In this mixin, we have three functions:

  • formatDate: Get a formatted string for a given date according to a specified format.
  • addMillisecondsToDate: Get a new date according to a given starting date and additional milliseconds.
  • getSampleDescriptorStr: Get a formatted string for a range value according to a given index and the sample-descriptors settings.

 

In order to use this mixin for showing the range values descriptions, we add another two computed-properties to our rangeComponent component:

JavaScript
const rangeComponent = {
    // ...

    mixins: [rangeOperationsMixin],
    computed: {
        minStr() {
            return this.getSampleDescriptorStr(this.min, this.settings.samplesDescriptors);
        },
        maxStr() {
            return this.getSampleDescriptorStr(this.max, this.settings.samplesDescriptors);
        },

        // ...
    },

    // ...
};

Pointed Values Indication

Pointed Values Description

So, we have a graph's drawing and a range control that presents the current range boundaries. Another useful option that we may want to see in our graph is showing the values for each graph point. For that purpose, we add another internal component:

JavaScript
const pointValuesComponent = {
    props: ['mouse_x', 'mouse_y', 'values_descriptor', 'values', 'settings'],
    template: 
		`<div class="sz-graph-point-values" :style="{ top: y, left: x }">
			<header>{{ values_descriptor }}</header>
			<div v-for="v in valuesDetails" >
				<span class="sz-graph-point-bullet" :style="{ background: v.color }">
					<span v-if="v.hasFill" :style="{ background: v.fillColor }"></span>
				</span>{{ v.value }}
			</div>
        </div>`,
    data() {
        return {
            x: '0',
            y: '0'
        };
    },
    computed: {
        valuesDetails() {
            const vals = this.values;
            const graphSettings = this.settings.drawing.graph;

            const res = [];

            // Get values descriptions for each point in the given values.
            for (let valInx = 0; valInx < vals.length; valInx++) {
                if (graphSettings.visibility[valInx]) {
                    let val = vals[valInx];

                    if (this.settings.pointIndication.digitsAfterDecimalPoint > 0) {
                        val = val.toFixed
                              (this.settings.pointIndication.digitsAfterDecimalPoint);
                    }

                    res.push({
                        color: graphSettings.showLines || graphSettings.showCircles
                            ? graphSettings.colors[valInx]
                            : graphSettings.fillColors[valInx],
                        fillColor: graphSettings.fillColors[valInx],
                        hasFill: graphSettings.showFill,
                        value: val
                    });
                }
            }

            return res;
        }
    }
};

In that component, we have a border with

  • a header with the description of the current pointed values.
  • a value descriptor for each presented graph. In this descriptor, we have
    • a bullet with the appropriate graph color indication, and
    • the value of the graph in the pointed location.

 

This border is shown when the mouse hover over the graph's drawing as will be described in the next section. One of the side-effects when showing additional content when the mouse is over is, that the additional content is considered as part of the parent element. So that, as long as the mouse is over the additional content (also if it is outside of the parent element), it is also considered as over the parent element. That behavior can lead to unwanted results. The additional content can be still displayed also when the mouse is out of the parent element (as long as it is over the additional content). In addition to the unwanted display of the additional content, it can also hide (and block access to) other elements.

In order to solve this problem, we position the pointed valued border according to mouses distance from the parent element boundaries. If the mouse is near to the parent element's left, we show the border the cursor's right side (and vice versa). If the mouse is near to the parent element's bottom, we show the border above the cursor (and vice versa). That can be done by watching the mouse position changes and, setting the border's position appropriately as follows:

JavaScript
watch: {
    mouse_x() {
        const el = this.$el;
        const width = el.offsetWidth;
        const parentWidth = el.parentNode.offsetWidth;
        const mouseX = this.mouse_x;

        // Set the x coordinate according to this element's width 
        // and the parent element's width.
        if (mouseX >= 0) {
            let res = mouseX;
            if (res > parentWidth / 2) {
                res -= width;
            }

            this.x = `${res}px`;
        }
    },
    mouse_y() {
        const el = this.$el;
        const height = el.offsetHeight;
        const parentHeight = el.parentNode.offsetHeight;
        const mouseY = this.mouse_y;

        // Set the y coordinate according to this element's height 
        // and the parent element's height.
        if (mouseY >= 0) {
            let res = mouseY;
            if (res > parentHeight / 2) {
                res -= height;
            }

            this.y = `${res}px`;
        }
    }
}

Showing Point Indication at Mouse Position

Now, with this pointValuesComponent, we can show the values of each point. In our solution, we want to show the values of the nearest (to the mouse cursor) point. For that purpose, we add computed-properties (in the drawingComponent component) for indicating the nearest point index:

JavaScript
const drawingComponent = {
    //...

    data() {
        return {
            mousePos: { x: -1, y: -1 },
            lastAbsolutePointValuesIndex: -1,
            lastPointValuesDescriptor: ''
        };
    },
    computed: {
        pointValuesIndex() {
            const el = this.$el;

            let res = -1;

            if (this.mousePos.x >= 0) {
                res = 0;

                if (this.range > 1) {
                    const width = el ? el.offsetWidth : 1;
                    const valueWidthUnit = width / this.range;
                    res = Math.round(this.mousePos.x / valueWidthUnit);
                }
            }

            return res;
        },
        absolutePointValuesIndex() {
            let res = this.pointValuesIndex;

            if (res >= 0) {
                res += this.range_offset;
            }

            if (this.lastAbsolutePointValuesIndex != res) {
                this.lastAbsolutePointValuesIndex = res;
                this.$emit('pointed-values-index-changed', res);
            }

            return res;
        }
    },
    methods: {
        onMouseMove(e) {
            const el = this.$el;
            let rect = el.getBoundingClientRect();

            this.mousePos.y = e.clientY - rect.top;
            this.mousePos.x = e.clientX - rect.left;
        },
        onMouseLeave(e) {
            // Indicate that there is no mouse inside the component.
            this.mousePos.y = -1;
            this.mousePos.x = -1;
        }
    }
};

Using these properties, we can compute the values at the pointed position:

JavaScript
const drawingComponent = {
    //...

    mixins: [rangeOperationsMixin],
    computed: {
        // ...

        pointValues() {
            let valueIndex = this.pointValuesIndex;

            if (valueIndex < 0) {
                valueIndex = 0;
            }

            return this.values.map(v => valueIndex < v.length ? v[valueIndex] : 0);
        },
        pointValuesDescriptor() {
            const valuesIndex = this.absolutePointValuesIndex;
            const res = valuesIndex >= 0 ? 
            this.getSampleDescriptorStr(valuesIndex, this.settings.samplesDescriptors) : '';

            if (this.lastPointValuesDescriptor != res) {
                this.lastPointValuesDescriptor = res;
                this.$emit('pointed-values-descriptor-changed', res);
            }

            return res;
        }
    }

    // ...
};

and, their coordinates in the graph:

JavaScript
const drawingComponent = {
    //...

    computed: {
        // ...

        pointValueX() {
            const el = this.$el;

            let res = 0;

            if (this.range > 1 && this.mousePos.x >= 0) {
                const width = el ? el.offsetWidth : 1;
                const valueWidthUnit = width / this.range;
                const valueIndex = Math.round(this.mousePos.x / valueWidthUnit);

                res = valueIndex * valueWidthUnit;
            }

            return res;
        },
        pointValuesY() {
            const values = this.values;
            const graphSettings = this.settings.drawing.graph;
            const el = this.$el;

            const res = [];

            if (this.range > 1 && this.mousePos.x >= 0) {
                const width = el ? el.offsetWidth : 1, height = el ? el.offsetHeight : 1;

                const valueWidthUnit = width / this.range;
                const valueHeightUnit = height / (this.max - this.min);
                const valueIndex = Math.round(this.mousePos.x / valueWidthUnit);

                for (let valInx = 0; valInx < values.length; valInx++) {
                    if (graphSettings.visibility[valInx]) {
                        if (valueIndex < values[valInx].length) {
                            res.push((this.max - 
                                      values[valInx][valueIndex]) * valueHeightUnit);
                        }
                    }
                }
            }

            return res;
        }
    }

    // ...
};

For showing the point values when the mouse is over, we add an additional element in the drawingComponent component's template:

JavaScript
const drawingComponent = {
    //...

    template: 
        `<div class="sz-graph-drawing" 
        @mousemove="onMouseMove" @mouseleave="onMouseLeave">
            <sz-graph-canvas v-if="settings.drawing.drawUsingCanvas" 
                :values="values" :range="range" :min="min" 
                :max="max" :settings="settings" 
                :highlighted_index="highlighted_index"></sz-graph-canvas>
            <sz-graph-svg v-else 
                :values="values" :range="range" 
                :min="min" :max="max" :settings="settings" 
                :highlighted_index="highlighted_index"></sz-graph-svg>
            <div class="sz-graph-point-indicator">
                <svg v-show="settings.pointIndication.showLines" 
                     class="sz-graph-point-lines">
                    <line :x1="pointValueX" y1="0" 
                    :x2="pointValueX" y2="100%" />
                    <line v-for="valY in pointValuesY" x1="0" :y1="valY" 
                     x2="100%" :y2="valY" />
                    <circle v-for="valY in pointValuesY" 
                    :cx="pointValueX" :cy="valY" r="4" />
                </svg>
                <sz-graph-point-values v-show="settings.pointIndication.showValues" 
                    :mouse_x="mousePos.x" :mouse_y="mousePos.y" 
                    :values_descriptor="pointValuesDescriptor" 
                    :values="pointValues" :settings="settings" ></sz-graph-point-values>
            </div>
        </div>`

    // ...
};

and, style it to appear only when the mouse is over the graph's drawing:

CSS
.sz-graph-point-indicator {
    opacity: 0;
    transition: opacity 0.5s;
}

.sz-graph-drawing:hover .sz-graph-point-indicator {
    opacity: 1;
    transition: opacity 0.4s;
}

In that element, we show SVG lines for indicating the position of each point in the scale and, a border with the points' values.

Composing Graph Content

Graph Visible View

After we have all the graph components, we can compose our graph view. For that purpose, we add another internal component:

JavaScript
const contentComponent = {
    props: ['values', 'settings', 'show_settings', 
            'highlighted_index', 'paused', 'back_offset'],
    components: {
        'sz-graph-scale': scaleComponent,
        'sz-graph-range': rangeComponent,
        'sz-graph-drawing': drawingComponent
    },
    template: 
        `<div class="sz-graph-content">
			<sz-graph-scale v-show="settings.scaleVisibility != 'collapsed'" 
                :style="{opacity: settings.scaleVisibility == 'hidden' ? 0 : 1}" 
                :min="scaleMin" :max="scaleMax" 
                :settings="settings"></sz-graph-scale>
			<sz-graph-range v-show="settings.rangeVisibility != 'collapsed'" 
                :style="{opacity: settings.rangeVisibility == 'hidden' ? 0 : 1}" 
                :min="rangeMin" :max="rangeMax" 
                :paused="paused" :back_offset="back_offset" 
                :settings="settings" @paused-changed="onPausedChanged" 
                @back-offset-changed="onBackOffsetChanged"></sz-graph-range>
			<sz-graph-drawing :values="visibleValues" :range="visibleRange" 
                :range_offset="rangeMin" :min="scaleMin" 
                :max="scaleMax" :settings="settings" 
                :highlighted_index="highlighted_index" 
                @pointed-values-index-changed="onPointedValueIndexChanged" 
                @pointed-values-descriptor-changed="onPointedValueDescriptorChanged">
             </sz-graph-drawing>
		 </div>`,
    methods: {
        onPausedChanged(newVal) {
            this.$emit('paused-changed', newVal);
        },
        onBackOffsetChanged(newVal) {
            this.$emit('back-offset-changed', newVal);
        },
        onPointedValueIndexChanged(newVal) {
            this.$emit('pointed-values-index-changed', newVal);
        },
        onPointedValueDescriptorChanged(newVal) {
            this.$emit('pointed-values-descriptor-changed', newVal);
        }
    }
};

As we saw above, our graph has a scale component that shows the scale values according to given boundaries, a range component that enables controlling the visible range and a drawing component that shows the visible values according to the scale and the visible range. In order to compute the needed values, we add some computed properties:

JavaScript
computed: {
    activeValues() {
        return this.paused ? this.pausedValues : this.values;
    },
    scaleMin() {
        const vals = this.activeValues;
        const scaleSettings = this.settings.scale;
        let res = 0;

        // Get the scale's minimum according to the graph's settings.
        if (vals.every(v => v.length < 2) || scaleSettings.hasMin) {
            // There is a minimum definition or there aren't enough values.
            // Use the settings definition.
            res = scaleSettings.min;
        }
        else {
            // There is no minimum definition.
            // Get the minimal value of the graph's values.
            res = Math.min(...vals.reduce((acc, v) => acc.concat(v), []));
        }

        // Round to integer if needed.
        if (scaleSettings.useIntegerBoundaries) {
            res = Math.floor(res);
        }

        return res;
    },
    scaleMax() {
        const vals = this.activeValues;
        const scaleSettings = this.settings.scale;
        let res = 100;

        // Get the scale's maximum according to the graph's settings.
        if (vals.every(v => v.length < 2) || scaleSettings.hasMax) {
            // There is a maximum definition or there aren't enough values.
            // Use the settings definition.
            res = scaleSettings.max;
        }
        else {
            // There is no maximum definition.
            // Get the maximal value of the graph's values.
            res = Math.max(...vals.reduce((acc, v) => acc.concat(v), []));
        }

        // Round to integer if needed.
        if (scaleSettings.useIntegerBoundaries) {
            res = Math.ceil(res);
        }

        return res;
    },
    rangeMin() {
        const settings = this.settings;

        const maxLength = Math.max(...this.activeValues.map(v => v.length));
        let minVal = 0;

        if (settings.hasGraphRange && maxLength > settings.graphRange) {
            minVal = maxLength - settings.graphRange - 1 - this.back_offset;
        }

        return minVal;
    },
    rangeMax() {
        const settings = this.settings;

        const maxLength = Math.max(...this.activeValues.map(v => v.length));
        let maxVal = maxLength - 1 - this.back_offset;

        if (settings.hasGraphRange && maxLength <= settings.graphRange) {
            maxVal = settings.graphRange;
        }

        return maxVal;
    },
    visibleValues() {
        const settings = this.settings;
        let vals = this.getValuesCopy(this.activeValues);

        if (settings.hasGraphRange) {
            const maxLength = Math.max(...vals.map(v => v.length));
            if (maxLength > settings.graphRange) {
                vals = vals.map(v => v.slice
                (v.length - 1 - settings.graphRange - this.back_offset, 
                 v.length - this.back_offset));
            }
        }

        return vals;
    },
    visibleRange() {
        return this.settings.hasGraphRange ? this.settings.graphRange :
            (this.activeValues.length > 0 ? this.activeValues[0].length - 1 : 0);
    }
}

In the activeValues property, we get the active values of the graph. As mentioned before, our graph can be paused and resumed. When the graph is paused, we don't want to show the values that were added to the graph after we paused it. For that purpose, when the graph gets paused, we store a copy of the graph's values and use that copy until the graph is resumed. We can get the graph's values copy when the graph gets paused by watching the paused property as follows:

JavaScript
data() {
    return {
        pausedValues: []
    };
},
watch: {
    paused() {
        if (this.paused) {
            this.pausedValues = this.getValuesCopy(this.values);
        }
    }
},
methods: {
    getValuesCopy(values) {
        return values.map(v => [...v]);
    }
    
    // ...
}

In the scaleMin property, we get the minimal scale value. This value can be defined in the graph's settings or be computed according to the graph's values boundaries. In the same manner, we get the maximal scale value using the scaleMax property.

In the rangeMin property, we get the index of the minimal value in the range. In our graph, we can present a view of all the values or, of a specified range. This function computes the minimal index that is shown, according to the range settings and the current offset (can be scrolled using the rangeComponent). In the same manner, we get the index of the maximal value in the range using the rangeMax property.

In the visibleValues property, we get the actual values that should be visible in the graph's drawing.

In the visibleRange property, we get the size of the active range.

Graph Legend

In addition to the displayed graph, sometimes we may also want to see a legend that describes the graph's values sets. For that purpose, we add another internal component:

JavaScript
const legendComponent = {
    props: ['values', 'settings'],
    template: 
        `<div class="sz-graph-legend">
            <div v-for="v in legendValues" >
                <span class="sz-graph-legend-bullet" :style="{ background: v.color }">
                    <span v-if="v.hasFill" :style="{ background: v.fillColor }"></span>
                </span>{{ v.title }}
            </div>
        </div>`,
    computed: {
        legendValues() {
            const graphSettings = this.settings.drawing.graph;

            let res = [];

            // Create an appropriate description for each values set.
            for (let valInx = 0; valInx < this.values.length; valInx++) {
                if (graphSettings.visibility[valInx]) {
                    res.push({
                        color: graphSettings.showLines || graphSettings.showCircles
                            ? graphSettings.colors[valInx]
                            : graphSettings.fillColors[valInx],
                        fillColor: graphSettings.fillColors[valInx],
                        hasFill: graphSettings.showFill,
                        title: graphSettings.titles[valInx]
                    });
                }
            }

            return res;
        }
    }
};

In that component, we have a border that shows the description of each values set. In our graph, we can posit the legend in the right, left, top or, bottom side of the graph. For that purpose, we use four different elements for presenting the graph's legend:

JavaScript
const contentComponent = {
    components: {
        'sz-graph-legend': legendComponent,
        // ...
    },
    template: 
        `<div class="sz-graph-content">
			<header>
				<sz-graph-legend v-if="settings.legendPosition=='top'" 
                    :values="activeValues" :settings="settings"></sz-graph-legend>
			</header>
			<aside class="sz-left">
				<sz-graph-legend v-if="settings.legendPosition=='left'" 
                    :values="activeValues" :settings="settings"></sz-graph-legend>
			</aside>
			<aside class="sz-right">
				<sz-graph-legend v-if="settings.legendPosition=='right'" 
                    :values="activeValues" :settings="settings"></sz-graph-legend>
			</aside>
			<footer>
				<sz-graph-legend v-if="settings.legendPosition=='bottom'" 
                    :values="activeValues" :settings="settings"></sz-graph-legend>
			</footer>

            <!-- ... -->
        </div>`
};

According to the graph's settings, we determine which element to show.

In addition to the legend, in order to enable editing the graph's settings, we add also a settings (settings image) button that emits a settings-clicked event when it is clicked:

JavaScript
const contentComponent = {
    // ...

    template: 
        `<div class="sz-graph-content">
            <!-- ... -->

			<button v-if="show_settings" class="sz-graph-settings" 
			@click="onSettingsClicked">
				<svg>
					<rect x="0" y="11%" width="40%" 
					height="8%" />
					<rect x="42.5%" y="1%" width="10%" 
					height="28%" rx="4%" ry="8%" />
					<rect x="55%" y="11%" width="45%" 
					height="8%" />
					<rect x="0" y="44%" width="75%" height="8%" />
					<rect x="77.5%" y="34%" width="10%" 
					height="28%" rx="4%" ry="8%" />
					<rect x="90%" y="44%" width="10%" 
					height="8%" />
					<rect x="0" y="77%" width="20%" height="8%" />
					<rect x="22.5%" y="67%" width="10%" 
					height="28%" rx="4%" ry="8%" />
					<rect x="35%" y="77%" width="75%" 
					height="8%" />
				</svg>
			</button>
        </div>`,

    // ...

    methods: {
        // ...

        onSettingsClicked() {
            this.$emit('settings-clicked');
        }
    }
};

The legend elements positions (and the other graph elements positions) are determined using a grid layout as follows:

CSS
.sz-graph-content {
    display: grid;
    position: absolute;
    height: 100%;
    width: 100%;
    margin: 0;
    grid-template-columns: auto auto 1fr auto;
    grid-template-rows: auto 1fr auto auto;
    grid-template-areas: "left . header right" "left scale graph right" 
                         "left settings range right" ". footer footer .";
}

.sz-graph-content header {
    grid-area: header;
    min-height: 0.5em;
}

.sz-graph-content footer {
    grid-area: footer;
}

.sz-graph-content .sz-left {
    grid-area: left;
    flex-direction: column;
}

.sz-graph-content .sz-right {
    grid-area: right;
    flex-direction: column;
}

.sz-graph-scale {
    grid-area: scale;
    position: relative;
    margin: 0;
    display: grid;
    grid-template-columns: 100%;
    grid-template-rows: 100%;
    grid-template-areas: "all";
    padding-right: 12px;
    border-right: solid 0.1em #333;
}

.sz-graph-range {
    grid-area: range;
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr auto auto;
    grid-template-areas: "min range button max";
    border-top: solid 0.1em #333;
    min-height: 0.5em;
}

.sz-graph-drawing {
    grid-area: graph;
    position: relative;
    margin-bottom: 0em;
}

.sz-graph-settings {
    position: relative;
    grid-area: settings;
    margin: 0.1em;
    margin-top: 0.5em;
}

Graph Settings

As mentioned along this article, we can control our graph's behavior using the graph's settings. The graph's settings is part of the root graph element's data:

JavaScript
Vue.component('sz-graph', {
    data() {
        return {
            settings: {
                scale: {
                    hasMin: false,
                    hasMax: false,
                    min: 0,
                    max: 100,
                    minimalValuesGap: 25,
                    useIntegerBoundaries: true
                },
                hasGraphRange: true,
                graphRange: 100,
                drawing: {
                    showGridLines: true,
                    gridLinesColor: '#dddddd',
                    graph: {
                        defaultColor: '#ff0000',
                        defaultFillColor: '#ffdddd',
                        colors: ['#bb0000', '#00bb00', '#bbbb00', 
                                 '#0000bb', '#bb00bb', '#00bbbb'],
                        fillColors: ['#ffbbbb', '#bbffbb', '#ffffbb', 
                                     '#bbbbff', '#ffbbff', '#bbffff'],
                        titles: [],
                        visibility: [],
                        showLines: true,
                        showCircles: true,
                        showFill: true
                    },
                    drawUsingCanvas: false
                },
                pointIndication: {
                    showValues: true,
                    showLines: true,
                    digitsAfterDecimalPoint: -1
                },
                samplesDescriptors: {
                    type: 'number', // One of: ['number', 'time'].
                    startingValue: 0,
                    stepSize: 1,
                    format: '',
                    digitsAfterDecimalPoint: 0
                },
                legendPosition: 'right',    // One of: ['right', 'left', 
                                                        'top', 'bottom', 'none'].
                scaleVisibility: 'visible', // One of ['visible', 'hidden', 'collapsed'].
                rangeVisibility: 'visible', // One of ['visible', 'hidden', 'collapsed'].
                showPauseButton: true,
                changesCounter: 0
            },
            isSettingsOpened: false
        };
    }
});

In our graph's settings, we have the following properties:

  • scale:
    • hasMin: Determines whether the graph scale has fixed minimal value
    • hasMax: Determines whether the graph scale has fixed maximal value
    • min: The fixed minimal scale value
    • max: The fixed maximal scale value
    • minimalValuesGap: The minimal distance (in pixels) between the values in the graph's scale
    • useIntegerBoundaries: Determines whether to round the scale minimal and maximal values to integers
  • hasGraphRange: Determines whether the graph shows all the values or, a partial range of them
  • graphRange: The displayed range size
  • drawing:
    • showGridLines: Determines whether to draw the graph's grid lines
    • gridLinesColor: The color of the graph's grid lines
    • graph:
      • defaultColor: The line color for graph index that isn't exist in the colors property
      • defaultFillColor: The fill color for graph index that isn't exist in the fillColors property
      • colors: The line color of each graph (by index)
      • fillColors: The fill color of each graph (by index)
      • titles: The title of each graph (to be displayed in the graph's legend)
      • visibility: Determines the visibility of each graph (by index)
      • showLines: Determines whether to draw lines, for the visible graphs
      • showCircles: Determines whether to draw circles at the actual values' positions, for the visible graphs
      • showFill: Determines whether to draw fills, for the visible graphs
    • drawUsingCanvas: Determines whether to use a canvas HTML element to draw the graphs (instead of a svg HTML element)
  • pointIndication:
    • showValues: Determines whether to show a border with the graphs' values (at the nearest point to the mouse cursor), when the mouse is over the graph's drawing
    • showLines: Determines whether to show scale indication lines (at the nearest point to the mouse cursor), when the mouse is over the graph's drawing
    • digitsAfterDecimalPoint: The number of digits after the decimal point, to display in the values border
  • samplesDescriptors:
    • type: The type of the sample's counter description. Can be number or time.
    • startingValue: The starting value of the samples counter.
    • stepSize: The size of step to increase the samples counter for each sample.
    • format: Time format. In use when the samples description type is time.
    • digitsAfterDecimalPoint: The number of digits after the decimal point, to display in the sample description. In use when the samples description type is number.
  • legendPosition: Determines where to display the graph's legend. Can be right, left, top, bottom or, none.
  • scaleVisibility: Determines whether to display the graph's scale. Can be visible, hidden or, collapsed.
  • rangeVisibility: Determines whether to display the graph's range. Can be visible, hidden or, collapsed.
  • showPauseButton: Determines whether to display the pause/resume button in the graph's range.
  • changesCounter: An internal counter for indicating changes in the graph's settings.

 

In order to let users update the graph's settings, we present a settings editor panel above the graph's content (when the user clicks on the settings button). This is done as follows:

JavaScript
Vue.component('sz-graph', {
    props: {
        'values': { type: Array, default: [] }
    },

    // ...

    components: {
        'sz-graph-content': contentComponent,
        'sz-graph-settings-panel': settingsPanelComponent
    },
    template: 
        `<div class="sz-graph">
			<sz-graph-content :values="fixedValues" :settings="settings" 
                :show_settings="show_settings" 
                :highlighted_index="highlighted_index" :paused="isPaused" 
                :back_offset="backOffset" @settings-clicked="onSettingsOpened">
            </sz-graph-content>
			<div v-if="isSettingsOpened" 
			class="sz-graph-settings-panel-border" ></div>
			<div class="sz-graph-settings-panel-container" 
                :class="{opened: isSettingsOpened}">
				<sz-graph-settings-panel :settings="settings" 
                                         :values_length="fixedValues.length" 
                    @closed="onSettingsClosed" ></sz-graph-settings-panel>
			</div>
        </div>`,
    computed: {
        fixedValues() {
            let res = this.values;

            if (Array.isArray(res)) {
                // Validate that we return an array of values arrays.
                if (!res.every(v => Array.isArray(v))) {
                    if (res.every(v => !Array.isArray(v))) {
                        // All the values aren't arrays. It's one array of values. 
                        // Return it as array of array.
                        res = [res];
                    } else {
                        res = res.map(v => Array.isArray(v) ? v : []);
                    }
                }
            } else {
                res = [];
            }

            this.fixGraphSettings(res);

            return res;
        }
    },
    methods: {
        onSettingsOpened() {
            this.isSettingsOpened = true;
        },
        onSettingsClosed() {
            this.isSettingsOpened = false;
            this.notifySettingsChanged();
        },
        notifySettingsChanged() {
            this.settings.changesCounter++;
            this.settings = Object.assign({}, this.settings);
            this.$emit('settings-changed', this.settings);
        },
        fixGraphSettings(graphValues) {
            const settings = this.settings;
            const valuesLength = graphValues.length;

            while (settings.drawing.graph.colors.length < valuesLength) {
                settings.drawing.graph.colors.push(settings.drawing.graph.defaultColor);
            }

            while (settings.drawing.graph.fillColors.length < valuesLength) {
                settings.drawing.graph.fillColors.push
                (settings.drawing.graph.defaultFillColor);
            }

            while (settings.drawing.graph.titles.length < valuesLength) {
                settings.drawing.graph.titles.push
                (`Graph ${settings.drawing.graph.titles.length + 1}`);
            }

            while (settings.drawing.graph.visibility.length < valuesLength) {
                settings.drawing.graph.visibility.push(true);
            }
        }
    }
});
CSS
@keyframes show-shadow-border {
    0% { background: rgba(0, 0, 0, 0); }
    100% { background: rgba(0, 0, 0, 0.4); }
}

.sz-graph-settings-panel-border {
    position: absolute;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.4);
    animation-name: show-shadow-border;
    animation-duration: 0.7s;
}

.sz-graph-settings-panel-container {
    position: absolute;
    top: 0.5em;
    left: 0.5em;
    right: 0.5em;
    bottom: 0.5em;
    display: flex;
    align-content: center;
    justify-content: center;
    opacity: 0;
    transform: scale(0, 0);
    transition: opacity 0.7s, transform 0.7s;
}

.sz-graph-settings-panel-container.opened {
    opacity: 1;
    transform: scale(1, 1);
    transition: opacity 0.6s, transform 0.6s;
    transition-timing-function: ease-out;
}

In the fixedValues computed property, we validate that the values collection that is used by the graph is an array of values arrays. The values that are set by the user can be an array of values arrays (as expected) or a single values array. In case of single values array, we convert it to array with one values array.

In the fixGraphSettings function, we adjust the graph's settings to the values arrays count.

The settingsPanelComponent component presents a border that presents the graph's settings and enables editing them:

Graph settings panel

When the user clicks the Settings button, we set the isSettingsOpened property to true (in the onSettingsOpened function) and, when the user clicks on the Close button, in the settings panel, we set it to false (in the onSettingsClosed function). In that way, we can show and hide the setting panel when it is needed.

For controlling the appearance of the settings panel border (a black shadow), we used v-if for adding and removing the HTML element when it is needed (instead of an element that changes its opacity), because we don't want it to hide the other elements (below it) from user interaction, when the settings panel is closed. So that (because CSS transition is for an existing element that changes its state and, CSS animation starts when the element is displayed), for animating the element appearance, we used an animation instead of a transition (as we used for the other animations in our graph).

Sometimes, too many possibilities can lead to too much confusion. In our case, showing a full settings panel for the graph can be more than what is needed. Sometimes, we may want to let the users editing only a part of the settings (or, not edit the settings at all). For that purpose, we add the option to not display the settings panel and, control the graph's settings outside of the graph. This can be done using the default_settings property:

JavaScript
Vue.component('sz-graph', {
    props: {
        // ...
        'default_settings': { type: Object, default: {} },
		'show_settings': { type: Boolean, default: true }
    },

    // ...

    mounted() {
        this.applyDefaultSettings();

        this.$watch('default_settings',
            () => {
                if (!this.show_settings) {
                    this.applyDefaultSettings();
                }
            });
    },
    methods: {
        // ...
        deepCopy(dst, src) {
            const isObject = obj => typeof obj === 'object';

            for (let p in dst) {
                if (dst[p] && isObject(dst[p])) {
                    if (src && src[p]) {
                        this.deepCopy(dst[p], src[p]);
                    }
                }
                else {
                    if (src && p in src) {
                        dst[p] = src[p];
                    }
                }
            }
        },
        applyDefaultSettings() {
            const defaultSettings = this.default_settings;

            if (defaultSettings) {
                this.deepCopy(this.settings, defaultSettings);
                this.fixGraphSettings(this.fixedValues);
                this.notifySettingsChanged();
            }
        }
    }
});

In the applyDefaultSettings function, we copy the default_settings to the graph's settings and notify about the settings' change. When the component is mounted, we call the applyDefaultSettings function and, if show_settings is set to false, we call it also every time default_settings is changed. In that way, if setting changes are disabled, we respect every change in the default_settings. Else, we respect it only at the first load.

In addition to the setting properties we add properties for: setting the index of the highlighted values-set, setting the pause state and, setting the offset of the presented range:

JavaScript
props: {
    // ...
    'highlighted_index': { type: Number, default: -1 },
    'paused': { type: Boolean, default: false },
    'back_offset': { type: Number, default: 0 }
}

Exposing Graph Events

Sometimes, we may want to define our own implementation for some internal events in our graph. We may want do some actions when the graph is paused or resumed or, when the user scrolled the graph's range to a different offset. We may want to show an external information about our graph (like a user-defined legend) and, update it appropriately when the graph's settings are changed. We may want to show the pointed values description in a different way than what's is provided with the graph's implementation (a border with the points' values and, lines for indication the position of each point in the scale) and, update it appropriately when the pointed values index is changed. In order to achieve that goal, we need a way to inform the user about the graph's events. That can be done by bubbling the events from the internal components to the root component. For example, let's see how it is done for the pointed-values-index-changed event:

JavaScript
const drawingComponent = {
    // ...

    computed: {
        // ...
        absolutePointValuesIndex() {
            let res = this.pointValuesIndex;

            if (res >= 0) {
                res += this.range_offset;
            }

            if (this.lastAbsolutePointValuesIndex != res) {
                this.lastAbsolutePointValuesIndex = res;
                this.$emit('pointed-values-index-changed', res);
            }

            return res;
        }
        /// ...
    }

    // ...
};

const contentComponent = {
    // ...

    template: 
        `<div class="sz-graph-content">
            <!-- ... -->
			<sz-graph-drawing :values="visibleValues" :range="visibleRange" 
                :range_offset="rangeMin" :min="scaleMin" 
                :max="scaleMax" :settings="settings" 
                :highlighted_index="highlighted_index" 
                @pointed-values-index-changed="onPointedValueIndexChanged" 
                @pointed-values-descriptor-changed="onPointedValueDescriptorChanged">
            </sz-graph-drawing>
            <!-- ... -->
        </div>`,
    methods: {
        // ...
        onPointedValueIndexChanged(newVal) {
            this.$emit('pointed-values-index-changed', newVal);
        }
        // ...
    }
};

Vue.component('sz-graph', {
    // ...

    template: `<div class="sz-graph">
					<sz-graph-content :values="fixedValues" :settings="settings" 
                        :show_settings="show_settings" :highlighted_index="highlighted_index" 
                        :paused="isPaused" :back_offset="backOffset" 
                                           @settings-clicked="onSettingsOpened" 
                        @paused-changed="onPausedChanged" 
                                         @back-offset-changed="onBackOffsetChanged" 
                        @pointed-values-index-changed="onPointedValueIndexChanged" 
                        @pointed-values-descriptor-changed="onPointedValueDescriptorChanged">
                    </sz-graph-content>
					<!-- ... -->
				  </div>`,
    methods: {
        // ...
        onPointedValueIndexChanged(newVal) {
            this.$emit('pointed-values-index-changed', newVal);
        },
        // ...
    }
});

How to Use It

Example 1 - Running Trend

For the first demonstration of our graph, we create a component that presents a running trend. In this component, we present four values' sets. The values for these values' sets are generated in run-time (each values' set has its own calculation method for calculating the values), according to changeable scale boundaries and update interval. This is done as follows:

HTML
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
    <link rel="stylesheet" type="text/css" href="sz_graph.css" />
    <link rel="stylesheet" type="text/css" href="Main.css" />
</head>
<body>
    <script src="js/vue.js"></script>
    <script src="js/sz_graph.js"></script>
    <script src="js/Main.js"></script>

    <main id="app1" class="main-frame">
        <h1 class="rt-header">Running trend</h1>
        <h1 class="ds-header">Daily samples</h1>
        <running-trend></running-trend>
        <daily-samples></daily-samples>
    </main>

    <script>
        window.onload = function () {
            initPage();
        };
    </script>
</body>
</html>
JavaScript
Vue.component('running-trend', {
    template: 
        `<div class="running-trend">
            <sz-graph class="rt-graph" 
            :values="values" :highlighted_index="highlightedIndex" 
                :default_settings="defaultSettings"></sz-graph>
        </div>`,
    data() {
        return {
            values: [],
            minValue: -99,
            maxValue: 99,
            updateInterval: 100,
            graph1Values: [],
            graph2Values: [],
            graph3Values: [],
            graph4Values: [],
            highlightedIndex: 1,
            defaultSettings: {
                drawing: {
                    graph: {
                        showCircles: false,
                        showFill: false
                    }
                },
                pointIndication: {
                    digitsAfterDecimalPoint: 3
                }
            }
        };
    },
    created() {
        this.UpdateGraphValues();
    },
    methods: {
        UpdateGraphValues() {
            const min = parseInt(this.minValue), max = parseInt(this.maxValue);

            if (min && min !== NaN && max && max !== NaN) {
                const scaleSize = max - min;

                this.graph1Values.push(Math.random() * scaleSize + min);
                this.graph2Values.push(Math.sin(Math.PI / 20 * this.graph2Values.length) * 
                                      (scaleSize / 2) + min + (scaleSize / 2));
                this.graph3Values.push(Math.cos(Math.PI / 15 * this.graph3Values.length) * 
                                      (scaleSize / 2) + min + (scaleSize / 2));
                this.graph4Values.push(Math.tan(Math.PI / 25 * this.graph4Values.length) * 
                            (scaleSize / 2) / 16 * Math.random() + min + (scaleSize / 2));

                this.values = [this.graph1Values, this.graph2Values, 
                               this.graph3Values, this.graph4Values];
            }

            setTimeout(() => {
                this.UpdateGraphValues();
            }, this.updateInterval);
        }
    }
});

let app1 = new Vue({
    el: '#app1'
});
CSS
body {
    background: rgb(199, 223, 255);
}

main {
    position: fixed;
    top: 0.5em;
    bottom: 0.5em;
    left: 0.5em;
    right: 0.5em;
}

.main-frame {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-gap: 0.5em;
    grid-template-rows: auto 1fr;
    grid-template-areas: "rt-header ds-header" "running-trend daily-samples";
    overflow: auto;
}

.main-frame > h1 {
    font-size: 1.8rem;
    font-weight: normal;
    font-family: Arial;
    margin: 0.2em;
    color: #060;
    background-image: radial-gradient(#ccc, #ffffff00 150%);
    border-radius: 40%;
    text-align: center;
}

.main-frame .rt-header {
    position:relative;
    grid-area: rt-header;
}

.main-frame .ds-header {
    position:relative;
    grid-area: ds-header;
}

.main-frame .running-trend {
    position: relative;
    grid-area: running-trend;
    display: grid;
    grid-template-columns: 48% 1fr;
    grid-template-rows: 1fr auto;
    grid-template-areas: "rt-graph rt-graph" "ud hd";
    grid-gap: 0.5em;
    background: #ccf;
    padding: 0.3em;
    border: 2px solid #048;
    border-radius: 0.5em;
    min-height: 15em;
    min-width: 30em;
}

.main-frame .daily-samples {
    grid-area: daily-samples;
    display: grid;
    grid-template-columns: 100%;
    grid-template-rows: 1fr auto auto;
    grid-template-areas: "ds-graph" "ds-legend-header" "ds-legend";
    grid-gap: 0.5em;
    background: #cfc;
    padding: 0.3em;
    border: 2px solid #084;
    border-radius: 0.5em;
    min-height: 15em;
}

For using our sz-graph component, we add a script element for loading the component and a link element for loading its style.

In this example, we set the graph's default settings to show only lines for the graph's (without circles and fill) and, show 3 digits after the decimal point when showing the pointed values description. The user can edit the graph's settings by clicking the settings button (Graph settings button).

In the UpdateGraphValues function, we generate a new value for each values' set and, call setTimeout for calling the function again when the update interval is elapsed. This function is called when the component is created.

For applying a background for the drawing area, we can style the graph's drawing component as follows:

CSS
.sz-graph-drawing {
    background: #ffc;
}

For controlling the generated values scale boundaries and their update interval, we add a border that enables editing these values:

JavaScript
Vue.component('running-trend', {
    template: 
        `<div class="running-trend">
            <sz-graph class="rt-graph" 
            :values="values" :highlighted_index="highlightedIndex" 
                :default_settings="defaultSettings"></sz-graph>
            <div class="update-details">
                <header>Generated values:</header>
                <div class="ud-min"><span>Min: </span>
                <input type="number" v-model="minValue" /></div>
                <div class="ud-max"><span>Max: </span>
                <input type="number" v-model="maxValue" /></div>
                <div class="ud-int"><span>Update interval: 
                </span><input type="number" 
                    v-model="updateInterval" /></div>
            </div>
        </div>`

    //  ...
});
CSS
.running-trend .update-details {
    position: relative;
    grid-area: ud;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: auto auto auto;
    grid-template-areas: "header header" "min max" "int int";
    grid-gap: 0.3em;
    background: #cfc;
    padding: 0.3em;
    border: 1px solid #084;
    border-radius: 0.2em;
}

.running-trend .update-details > div {
    position: relative;
    display: flex;
    align-items: center;
}

.running-trend .update-details > div input {
    position: relative;
    flex-grow: 1;
    width: 3em;
}

.running-trend .update-details header {
    position: relative;
    grid-area: header;
    color: #060;
}

.running-trend .ud-min {
    position: relative;
    grid-area: min;
}

.running-trend .ud-max {
    position: relative;
    grid-area: max;
}

.running-trend .ud-int {
    position: relative;
    grid-area: int;
}

In addition to presenting the graph, we provide an option to select the highlighted graph index and, present the minimum, maximum, average and, median of its values' set. For that purpose, we add an additional border:

JavaScript
Vue.component('running-trend', {
    template: 
        `<div class="running-trend">
            <sz-graph class="rt-graph" :values="values" 
            :highlighted_index="highlightedIndex" 
                :default_settings="defaultSettings"></sz-graph>
            <div class="update-details">
                <header>Generated values:</header>
                <div class="ud-min"><span>Min: </span>
                <input type="number" v-model="minValue" /></div>
                <div class="ud-max"><span>Max: </span>
                <input type="number" v-model="maxValue" /></div>
                <div class="ud-int"><span>Update interval: 
                </span><input type="number" 
                    v-model="updateInterval" /></div>
            </div>
            <div class="highlighted-data">
                <div class="hd-hi">Highlighted graph index: 
                    <select v-model="highlightedIndex">
                        <option v-for="i in graphsIndexes" :value="i">{{ i }}</option>
                    </select>
                </div>
                <div class="hd-min">Min: 
                <span class="hd-val">{{ minGraphValue }}</span></div>
                <div class="hd-max">Max: 
                <span class="hd-val">{{ maxGraphValue }}</span></div>
                <div class="hd-avg">Average: 
                <span class="hd-val">
                 {{ averageGraphValue }}</span></div>
                <div class="hd-med">Median: 
                <span class="hd-val">
                 {{ medianGraphValue }}</span></div>
            </div>
        </div>`,
    computed: {
        graphsIndexes() {
            return this.values.map((v, i) => i);
        },
        minGraphValue() {
            return Math.min(...this.values[this.highlightedIndex]).toFixed(3);
        },
        maxGraphValue() {
            return Math.max(...this.values[this.highlightedIndex]).toFixed(3);
        },
        averageGraphValue() {
            const valuesLength = this.values[this.highlightedIndex].length;
            const sum = this.values[this.highlightedIndex].reduce((acc, v) => acc + v, 0);
            const avg = valuesLength > 0 ? sum / valuesLength : 0;

            return avg.toFixed(3);
        },
        medianGraphValue() {
            let res = 0;

            const sortedValues = [...this.values[this.highlightedIndex]].sort(
                (a, b) => a < b ? -1 : (a > b ? 1 : 0));

            if (sortedValues.length > 0) {
                if (sortedValues.length % 2 == 0) {
                    // Even length.
                    const medianIndex = sortedValues.length / 2;
                    res = (sortedValues[medianIndex] + sortedValues[medianIndex - 1]) / 2;
                } else {
                    // Odd length.
                    res = sortedValues[(sortedValues.length - 1) / 2];
                }
            }

            return res.toFixed(3);
        }
    }

    //  ...
});
CSS
.running-trend .highlighted-data {
    position: relative;
    grid-area: hd;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: auto auto auto;
    grid-template-areas: "hi hi" "min max" "avg med";
    background: #cfc;
    padding: 0.3em;
    border: 1px solid #084;
    border-radius: 0.2em;
}

.running-trend .hd-hi {
    position: relative;
    grid-area: hi;
}

.running-trend .hd-min {
    position: relative;
    grid-area: min;
}

.running-trend .hd-max {
    position: relative;
    grid-area: max;
}

.running-trend .hd-val {
    color: blue;
}

.running-trend .hd-avg {
    position: relative;
    grid-area: avg;
}

.running-trend .hd-med {
    position: relative;
    grid-area: med;
}

The result is:

Running trend

Example 2 - Daily Samples

For the second demonstration of our graph, we create a component that presents a graph with generated samples. In this component, we have six sets of samples values, that can be displayed all in one graph or, separated to a different graph for each samples set. This is done as follows:

JavaScript
Vue.component('daily-samples', {
    template: 
        `<div class="daily-samples">
            <sz-graph v-if="isAllInOne" class="ds-graph" :values="values" 
                :default_settings="allInOneGraphSettings" :show_settings="false" 
                :paused="true" :back_offset="backOffset" 
                 @back-offset-changed="onBackOffsetChanged"></sz-graph>
            <div v-else class="ds-single-graphs">
            <sz-graph v-if="allInOneGraphSettings.drawing.graph.visibility[i]" 
             v-for="(n, i) in values.length" 
                :key="i" :values="values[i]" 
                :default_settings="singleGraphsSettings[i]" :show_settings="false" 
                :paused="true" :back_offset="backOffset" 
                 @back-offset-changed="onBackOffsetChanged"></sz-graph>
            </div>
        </div>`,
    data() {
        return {
            values: [],
            isAllInOne: true,
            backOffset: 10,
            allInOneGraphSettings: {
                legendPosition: 'none',
                scale: {
                    hasMin: true,
                    hasMax: true,
                    min: 0,
                    max: 100
                },
                hasGraphRange: true,
                graphRange: 24,
                drawing: {
                    graph: {
                        colors: ['#bb0000', '#00bb00', '#bbbb00', 
                                 '#0000bb', '#bb00bb', '#00bbbb'],
                        fillColors: ['#ffbbbb', '#bbffbb', '#ffffbb', 
                                     '#bbbbff', '#ffbbff', '#bbffff'],
                        titles: ['Aug 16 2020', 'Aug 17 2020', 'Aug 18 2020', 
                                 'Aug 19 2020', 'Aug 20 2020', 'Aug 21 2020'],
                        visibility: [true, true, false, false, true, true]
                    }
                },
                pointIndication: {
                    showValues: false,
                    digitsAfterDecimalPoint: 3
                },
                samplesDescriptors: {
                    type: 'time',
                    startingValue: new Date(2020, 8, 22, 8),
                    stepSize: 600000, // 10 minutes
                    format: 'HH:mm'
                },
                showPauseButton: false
            },
            singleGraphsSettings: []
        };
    },
    created() {
        for (let i = 0; i < 6; i++) {
            const vals = [];

            for (let j = 0; j < 73; j++) {
                vals.push(5 + 90 * Math.random());
            }

            this.values.push(vals);

            const singleGraphSettings = {
                legendPosition: 'none',
                scale: {
                    hasMin: true,
                    hasMax: true,
                    min: 0,
                    max: 100
                },
                hasGraphRange: true,
                graphRange: 24,
                drawing: {
                    graph: {
                        colors: [this.allInOneGraphSettings.drawing.graph.colors[i]],
                        fillColors: [this.allInOneGraphSettings.drawing.graph.fillColors[i]],
                        titles: [this.allInOneGraphSettings.drawing.graph.titles[i]],
                    }
                },
                pointIndication: {
                    showValues: false,
                    digitsAfterDecimalPoint: 3
                },
                samplesDescriptors: {
                    type: 'time',
                    startingValue: new Date(2020, 8, 22, 8),
                    stepSize: 600000, // 10 minutes
                    format: 'HH:mm'
                },
                showPauseButton: false
            };

            this.singleGraphsSettings.push(singleGraphSettings);
        }
    },
    methods: {
        onBackOffsetChanged(newVal) {
            this.backOffset = newVal;
        }
    }
});

In this component's template, we have a sz-graph element for displaying all the samples graphs together when isAllInOne is true and, a set of sz-graph elements for displaying each samples set in a separated graph otherwise. We synchronize the range scrolling of all the graphs by handling the back-offset-changed event to set the back_offset of all the graphs. When the component is created, we initialize the samples values and the settings for each samples set. As we can see,

  • we disabled the editing of the graph's settings (by setting show_settings to false).
  • In the provided settings, we have
    • a fixed scale (from 0 to 100).
    • Range size of 24 samples.
    • Point indication settings that display only the scale indication lines.
    • Samples descriptors of type time with HH:mm format and, a step size of 10 minutes.

 

In addition to presenting the graph, we present a user defined legend that can control the visibility of each samples set. In this legend, we also display the values of the graphs in the pointed location. This is done by adding another component for presenting a legend entry:

JavaScript
Vue.component('legend-entry', {
    props: ['color', 'fill', 'title', 'index', 
            'checked', 'has_pointed_value', 'pointed_value'],
    data() {
        return {
            isChecked: true
        };
    },
    template: 
        `<div>
            <input type="checkbox" v-model="isChecked" />
            <span class="sz-graph-legend-bullet" :style="{ background: color }">
                <span :style="{ background: fill }"></span>
            </span>
            <div class="le-title">
                <div>{{ title }}</div>
                <div class="le-point-value" 
                :style="{opacity: has_pointed_value ? 1 : 0}" >
                    {{ pointed_value }}</div>
            </div>
        </div>`,
    mounted() {
        this.isChecked = this.checked;
    },
    watch: {
        checked() {
            this.isChecked = this.checked;
        },
        isChecked() {
            this.$emit('checked-changed', { index: this.index, checked: this.isChecked });
        }
    }
});

and, use it in the daily-samples component as follows:

JavaScript
Vue.component('daily-samples', {
    template: 
        `<div class="daily-samples">
            <sz-graph v-if="isAllInOne" class="ds-graph" :values="values" 
                :default_settings="allInOneGraphSettings" :show_settings="false" 
                :paused="true" :back_offset="backOffset" 
                @back-offset-changed="onBackOffsetChanged"
                @pointed-values-index-changed="onPointedValueIndexChanged" 
                @pointed-values-descriptor-changed="onPointedValueDescriptorChanged">
             </sz-graph>
            <div v-else class="ds-single-graphs">
            <sz-graph v-if="allInOneGraphSettings.drawing.graph.visibility[i]" 
             v-for="(n, i) in values.length" 
                :key="i" :values="values[i]" 
                :default_settings="singleGraphsSettings[i]" :show_settings="false" 
                :paused="true" :back_offset="backOffset" 
                @back-offset-changed="onBackOffsetChanged"
                @pointed-values-index-changed="onPointedValueIndexChanged" 
                @pointed-values-descriptor-changed="onPointedValueDescriptorChanged">
            </sz-graph>
            </div>
            <div class="ds-legend-header">
                <div class="ds-lh-desc" :style="{opacity: hasPointedValues ? 1 : 0}">
                    Pointed values time: <span> {{ pointedValuesDescriptor }} </span></div>
                <div><label><input type="checkbox" v-model="isAllInOne" /> 
                 All in one</label></div>
            </div>
            <footer class="ds-legend">
                <div class="sz-graph-legend">
                <legend-entry v-for="(n, i) in values.length" :key="i" :index="i"
                            :color="allInOneGraphSettings.drawing.graph.colors[i]" 
                            :fill="allInOneGraphSettings.drawing.graph.fillColors[i]" 
                            :title="allInOneGraphSettings.drawing.graph.titles[i]" 
                            :checked="allInOneGraphSettings.drawing.graph.visibility[i]"
                            :has_pointed_value="hasPointedValues"
                            :pointed_value="pointedValues[i]"
                            @checked-changed="onCheckedChanged"></legend-entry>
                </div>
            </footer>
        </div>`,
    data() {
        return {
            // ...
            hasPointedValues: false,
            pointedValues: [],
            pointedValuesDescriptor: ''
        };
    },
    methods: {
        onCheckedChanged(ev) {
            this.allInOneGraphSettings.drawing.graph.visibility[ev.index] = ev.checked;
            this.allInOneGraphSettings = Object.assign({}, this.allInOneGraphSettings);
        },
        onPointedValueIndexChanged(newVal) {
            if (newVal >= 0) {
                this.pointedValues = this.values.map(vals => vals[newVal].toFixed(2));
                this.hasPointedValues = true;
            } else {
                this.hasPointedValues = false;
            }
        },
        onPointedValueDescriptorChanged(newVal) {
            this.pointedValuesDescriptor = newVal;
        }
    }
});

In the legend entry, we show

  • a check-box for controlling the visibility of each graph.
  • a bullet that is styles as same as the default legend bullet (has the same class).
  • a title for describing the samples set.
  • the graphs value at the pointed location for the samples set.

 

For controlling the visibility of each samples set graph, we handle the checked-changed event to set the visibility for the appropriate graph.

For displaying the values of the graphs in the pointed location, we handle the pointed-values-index-changed and the pointed-values-descriptor-changed events to update the appropriate properties.

The result is:

Daily samples graph

The layout of our examples is to present the both of the examples side by side (when the view width is big enough). When the view width isn't big enough, we present the examples one below the other. This is done using a media query as follows:

CSS
@media only screen and (max-width: 75rem) {
    .main-frame {
        grid-template-columns: 1fr auto;
        grid-template-rows: 1fr 1fr;
        grid-template-areas: "running-trend rt-header" "daily-samples ds-header";
    }

    .main-frame > h1 {
        writing-mode: vertical-lr;
    }
}

The result is:

All graphs

History

  • 4th December, 2020: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License

Share

About the Author

Shmuel Zang
Software Developer
Israel Israel
No Biography provided

Comments and Discussions

 
-- There are no messages in this forum --