This article shows how we can create a component that presents a samples graph using Vue.
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):
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:
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:
methods: {
drawGraph() {
const c = this.$el;
c.width = c.offsetWidth;
c.height = c.offsetHeight;
const w = c.width, h = c.height;
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, w, h);
ctx.setLineDash([]);
if (this.settings.drawing.showGridLines) {
this.drawGridLines(ctx, w, h);
}
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;
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];
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();
}
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();
}
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:
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:
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;
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;
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 = [];
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:
const scaleComponent = {
props: ['min', 'max', 'settings']
};
In that component, we add a computed property for the presented scale values:
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:
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:
.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:
.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:
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 (!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:
.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 () that is shown when the graph is paused and a pause graphic () that is shown otherwise. For notifying about the paused state changes, we emit a paused-changed
event for each click:
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:
samplesDescriptors: {
type: 'number',
startingValue: 0,
stepSize: 1,
format: '',
digitsAfterDecimalPoint: 0
}
For calculating the actual range values, we create a mixin with the needed operations:
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:
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:
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 = [];
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:
watch: {
mouse_x() {
const el = this.$el;
const width = el.offsetWidth;
const parentWidth = el.parentNode.offsetWidth;
const mouseX = this.mouse_x;
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;
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:
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) {
this.mousePos.y = -1;
this.mousePos.x = -1;
}
}
};
Using these properties, we can compute the values at the pointed position:
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:
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:
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:
.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:
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:
computed: {
activeValues() {
return this.paused ? this.pausedValues : this.values;
},
scaleMin() {
const vals = this.activeValues;
const scaleSettings = this.settings.scale;
let res = 0;
if (vals.every(v => v.length < 2) || scaleSettings.hasMin) {
res = scaleSettings.min;
}
else {
res = Math.min(...vals.reduce((acc, v) => acc.concat(v), []));
}
if (scaleSettings.useIntegerBoundaries) {
res = Math.floor(res);
}
return res;
},
scaleMax() {
const vals = this.activeValues;
const scaleSettings = this.settings.scale;
let res = 100;
if (vals.every(v => v.length < 2) || scaleSettings.hasMax) {
res = scaleSettings.max;
}
else {
res = Math.max(...vals.reduce((acc, v) => acc.concat(v), []));
}
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:
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:
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 = [];
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:
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 () button that emits a settings-clicked
event when it is clicked:
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:
.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:
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',
startingValue: 0,
stepSize: 1,
format: '',
digitsAfterDecimalPoint: 0
},
legendPosition: 'right',
'top', 'bottom', 'none'].
scaleVisibility: 'visible',
rangeVisibility: 'visible',
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:
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)) {
if (!res.every(v => Array.isArray(v))) {
if (res.every(v => !Array.isArray(v))) {
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);
}
}
}
});
@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:
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:
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:
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:
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:
<!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>
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'
});
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 ().
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:
.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:
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>`
});
.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:
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) {
const medianIndex = sortedValues.length / 2;
res = (sortedValues[medianIndex] + sortedValues[medianIndex - 1]) / 2;
} else {
res = sortedValues[(sortedValues.length - 1) / 2];
}
}
return res.toFixed(3);
}
}
});
.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:
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:
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,
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,
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:
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:
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:
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:
@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:
- 4th December, 2020: Initial version