Привет! Учусь на front-end, и параллельно, в учебном проекте, разрабатываю SPA на Vue.js для back-end, который собирает данные от поискового бота. Бот нарабатывает от 0 до 500 записей, и я должен их: загрузить, отсортировать по заданным критериям, показать в таблице.
Ни back-end ни бот, сортировать данные не умеют, поэтому мне приходятся загружать все данные и обрабатывать их на стороне браузера. Сортировка происходит очень быстро, а вот скорость загрузки, зависит о коннекта, и указанные 500 записей могут загружаться от 10 до 40 секунд.
Поначалу, при загрузке, я показывал спинер, недостаток которого — пользователь не знает когда закончится загрузка. В моём случае заранее известно количество записей которые отыскал бот, поэтому можно показать сколько % записей загружено.
Чтобы скрасить пользователю ожидание, я решил показать ему процесс загрузки:
- цифрами — сколько % записей уже загружено
- графиком — время загрузки каждой записи
- заполнением — % загрузки. Так как график по мере загрузки заполняет прямоугольный блок, видно, какую часть блока осталось заполнить
Вот анимация результата, к которому я стремился и получил:
… по-моему, получилось забавно.
В статье я покажу как продвигался к результату шаг за шагом. Графики функций в браузере я до селе не рисовал, поэтому разработка индикатора принесла мне простые, но новые знания о применении SVG и Vue.
Выбор способа отрисовки Canvas или SVG
Canvas я применял в простой игре-змейке на JS, а SVG, в одном проекте, я просто вставлял на страницу в теге object и заметил, что при масштабировании, SVG-картинки всегда сохраняли чёткость (на то он и вектор) а у Canvas наблюдалось размытие изображения. На основании этого наблюдения, я решил рисовать график с помощью SVG, ведь надо когда-то начинать.
План работ
С учётом выбранного фреймворка Vue, и выбранного способа формирования изображения с помощью SVG, составил себе следующий план работ:
- Поиск и изучение информации по теме применения SVG совместно с Vue
- Эксперименты с формированием и изменением SVG в контексте Vue
- Создание прототипа индикатора загрузки
- Выделение индикатора загрузки в отдельный Vue-компонент
- Применение компонента в SPA
Приступаю к реализации
- Создание заготовки проекта
У меня установлен vue cli. Для создания нового проекта, в командной строке ввожу vue create loadprogresser, настройки проекта выбираю default создаётся новый vue-проект с названием loadprogresser, дальше убираю из него лишнее:
Было Стало
Структура проекта по default
Структура после «уборки»
Приветствие от Vue
<template> <div> <h1>Progresser</h1> </div> </template> <script> export default {name: 'app'} </script>;
Мой текст «Progresser» в App.vue
-
Поиск и изучение информации по теме применения SVG совместно с Vue
Отличный сайт с полезной инфой по HTML, CSS и SVG css.yoksel.ru Хороший пример с SVG размещён в документации самого Vue SVG-график Example и по такой ссылочке. На основе этих материалов родился минимальный шаблон компонента с SVG с которого я и стартую:
<template> <div class="wrapper"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"> //svg заполняет весь контейнер //сюда буду вставлять svg-теги </svg> </div> </template>
-
Эксперименты с формированием и изменением SVG в контексте Vue
SVG Прямоугольник rect
rect — прямоугольник, самая простая фигура. Создаю svg с размерами 100х100px, и рисую прямоугольник rect с начальными координатами 25:25 и размерами 50х50 px, по умолчанию цвет заливки чёрный (нет стилизации)
SVG стилизация и псевдокласс hover:
Попробую стилизовать прямоугольник rect в svg. Для этого к svg добавляю класс «sample», в секции style vue-файла добавляю стили .sample rect (раскрашиваю прямоугольник rect жёлтым цветом) и .sample rect:hover который стилизует элемент rect при наведении на него курсора мыши:
Исходник<template> <div id="app"> <svg class="sample" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100px" height="100px"> <rect x=25 y=25 width="50px" height="50px"/> </svg> </div> </template> <script> export default { name: 'app' } </script> <style> .sample rect { fill: yellow; stroke: green; stroke-width: 4; transition: all 350ms; } .sample rect:hover { fill: gray; } </style>
Реализация на JSfiddle
Вывод: svg отлично встраивается в template vue-файла и стилизуется прописанными стилями. Начало положено!
SVG path как основа индикатора
В этом разделе я заменю rect на path,
<path :d="D" class="path"/>
в атрибут d тега path передам из vue строку D с координатами пути. Связь производится черезv-bind:d="D"
, что сокращённо записывается как:d="D"
Строка D=«M 0 0 0 50 50 50 50 0 Z» рисует три линии с координатами 0:0->0:50->50:50->0:50 и замыкает контур по команде Z, образуя квадрат 50х50px начинающийся из коодинат 0:0. С помощью стиля «path» фигуре придаётся жёлтый цвет заполнения и серая рамка в 1px.
Исходник жёлтого PATH<template> <div id="app"> <svg class="sample" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100px" height="100px"> <path :d="D" class="path"/> </svg> </div> </template> <script> export default { name: 'app', data(){ return { D:"M 0 0 0 50 50 50 50 0 Z" } } } </script> <style> .path { fill:yellow; stroke:gray; } </style>
-
Создание прототипа индикатора загрузки
В минимальном варианте, я сделал простую диаграмму. В шаблоне вставлен svg-контейнер высотой 100px, шириной 400px, внутри размещён тег path, атрибуту d которого я добавляю сгенерированную строку-путь d из данных vue, которая в свою очередь формируется из массива timePoints куда, каждые 10мс, добавляются одно из 400 (по ширине контейнера)случайное число в диапазоне от 0 до 100. Тут всё просто, в хуке жизненного цикла created, вызывается метод update в котором добавляются новые (случайные) точки в диаграмму через метод addTime, потом метод getSVGTimePoints возвращает строку для передачи в PATH, через setTimeout перезапускается метод update
Подробнее о формировании строки для PATH
Строка для PATH формируется в методе getSVGTimePoints, из массива timePoints который я обрабатываю с помощью reduce. В качестве начального значения reduce использую «M 0 0» (начать с координаты 0:0). Далее в reduce, к строке будут добавляться новые пары относительных координат dX и dY. За то, что координаты будут относительными, отвечает прописная буква «l» (большая «L» сообщает о абсолютных координатах), после «l» размещается dX и потом dY, разделённых пробелами. В этом прототипе, dY = 1 (приращение на 1px), в дальнейшем, по оси X буду перемещаться с приращением dX вычисленным из ширины контейнера и количества точек которые в нём необходимо разместить. В последней строке формирования PATH
path +=`L ${this.timePoints.length} 0`
я принудительно, от последней точки, достраиваю линию до оси Х. Если потребуется замкнуть контур, можно дописать в конец строки «Z», я поначалу думал, что без замкнутого контура, полученная фигура не будет заполняться (fill) но это оказалось не так, там где не замкнуто, не будет прорисована stroke — обводка.
getSVGTimePoints:function(){ let predY = 0 let path = this.timePoints.reduce((str, item)=>{ let dY = item - predY predY = item return str + `l 1 ${dY} ` },'M 0 0 ') path +=`L ${this.timePoints.length} 0`// Z` контур можно не замыкать return path },
Продолжу вносить изменения. Мой индикатор должен масштабироваться по ширине и высоте для того чтобы все переданные точки вписались в заданный контейнер. Для этого надо обратится к DOM и узнать размеры контейнера
-
ref — получение информации о элементе DOM
DIV-ву контейнеру (в который вставлен svg) я добавляю класс wrapper чтобы передать ширину и высоту через стили. И чтобы svg занял всё пространство контейнера, задал его высоту и ширину 100%. RECT, в свою очередь, тоже займёт всё пространство контейнера и будет фоном для PATH
<div id="app" class="wrapper" ref="loadprogresser"> <svg id="sample" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"> <rect x=0 y=0 width="100%" height="100%"/> <path :d="d" fill="transparent" stroke="black"/> </svg> </div>
Для того чтобы найти мой DIV-контейнер в виртуальном DOM Vue, добавляю атрибут ref и присваиваю ему имя по которому буду осуществлять поиск
ref="loadprogresser"
. В хуке жизненного циклаmounted
я вызову метод getScales(), в котором, строкойconst {width, height} = this.$refs.loadprogresser.getBoundingClientRect()
узнаю ширину и высоту DIV-элемента после его появления в DOM.
Дальше простые расчёты приращения по оси Х зависящего от ширины контейнера и кол-ва точек которые хотим в него уместить. Масштаб по оси Y пересчитывается каждый раз при нахождении максимума в переданном значении.
transform — изменение системы координат
На этом этапе я замечаю, что надо бы изменить систему координат так, чтобы координата 0:0 начиналась из нижнего левого угла, и ось Y росла бы вверх, а не вниз. Можно, конечно, сделать расчёты для каждой точки, но в SVG есть атрибут transform, позволяющий трансформировать координаты.
В моём случае требуется применять к Y координатам масштаб -1 (чтобы значения Y откладывались вверх), и сместить начало координат на минус высоту контейнера. Так как высота контейнера может быть любой (задаётся через стили), то пришлось формировать строку трансформации координат в хуке
mounted
таким кодом:this.transform = `scale( 1, -1) translate(0,${-this.wrapHeight})`
Но сама по себе трансформация применённая к PATH не сработает, для этого надо обернуть PATH в группу (тег g) к которой и применить трансформации координат:
<g :transform="transform"> <path :d="d" fill="transparent" stroke="black"/> </g>
В итоге координаты правильно развернулись, индикатор загрузки стал ближе к задуманному дизайну
SVG text и центрирование текста
Текст нужен для вывода % загрузки. Размещение текста в центре по вертикали и горизонтали в SVG довольно просто организовать (по сравнению с HTML/CSS), на помощь приходят атрибуты (сразу прописываю значения) dominant-baseline=«central» и text-anchor=«middle»
Текст в SVG выводится соответствующим тегом:
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle">{{TextPrc}}</text>
где TextPrc привязка к соответствующей переменной, вычисляемой по простому соотношению ожидаемого количества точек к переданному количеству
this.TextPrc = `${((this.Samples * 100)/this.maxSamples) | 0} %`
.
Координаты начала x=«50%» y=«50%» соответствуют центру контейнера, а за то чтобы текст выровнялся по вертикали и горизонтали, отвечают атрибуты dominant-baseline и text-anchor.
Базовые вещи по теме отработаны, теперь надо выделить прототип индикатора в отдельный компонент.
-
-
Выделение индикатора загрузки в отдельный Vue-компонент
Для начала определюсь с данными которые буду передавать в компонент, это будут: maxSamples — кол-во сэмплов в 100%-ах ширины, и Point — единица данных (точка) которая будет внесена в массив точек (на основании которого, после обработки, сформируется график). Данные передаваемые компоненту от родителя, размещаю в секции props
props:{ maxSamples: {//кол-во сэмплов в 100%-ах ширины type: Number, default: 400 }, Point:{//новая точка value:0 } }
Проблемы с реактивностью
За то, что новая переданная в компонент точка будет обработана, отвечает computed свойство getPath которое зависит от Point (а раз зависит, то и перевычисляется при изменении Point)
//шаблон ... <path :d="getPath"/> ... //свойства компонента props:{ ... Point:{ value:0 } //вычисляемое свойство computed:{ getPath(){ this.addValue({value:this.Point.value}) return this.getSVGPoints()//this.d } },
Я сначала сделал Point типа Number, что логично, но тогда не все точки попадали в обработку, а только отличающиеся от предыдущих. Например, если из родителя передавать в такой Point только число 10, то на графике отрисуется только одна точка, все последующие будут проигнорированы так как они не отличаются от предыдущих.
Замена типа Point с Number на объект {value:0} привело к желаемому результату — computed свойство getPath() теперь обрабатывает каждую переданную точку, через Point.value передаю значения точек
Исходник компонента Progresser.vue<template> <div class="wrapper" ref="loadprogresser"> <svg class="wrapper__content" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" > <g :transform="transform"> <path :d="getPath"/> </g> <text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"> {{TextPrc}} </text> </svg> </div> </template> <script> export default { props:{ maxSamples: {//кол-во сэмплов в 100%-ах ширины type: Number, default: 400 }, Point:{ value:0 } }, data(){ return { Samples:0,//номер текущего сэмпла wrapHeight:100,//высота в которую надо вписать изображение maxY:0,//максимальная по Y величина (для вертикального масштабирования) scales:{ w:1,//масштабирование по горизонтали (рассчитывается по ширине контейнера) h:1 //масштабирование по вертикали //(пересчитывается по максимальному Y и высоте контейнера) }, Points:[],//массив значений времени выполнения операции (получены из Point.value) transform:'scale( 1, -1) translate(0,0)', TextPrc: '0%' } }, mounted: function(){ this.getScales() }, methods:{ getScales(){ const {width, height} = this.$refs.loadprogresser.getBoundingClientRect() //Коэф. Масштабирования по горизонтали this.scales.w = width / this.maxSamples //трансформация координат Y от высоты контейнера this.wrapHeight = height this.transform = `scale( 1, -1) translate(0,${-this.wrapHeight}) rotate(0)` }, getVScale(){//расчёт масштаба по вертикали this.scales.h = (this.maxY == 0)? 0 : this.wrapHeight / this.maxY }, //передаю значения выделяю максимум и выдаю шкалу относительно этого максимума getYMax({value = 0}){ this.maxY = (value > this.maxY) ? value : this.maxY }, addValue({value = 0}){ if (this.Samples < this.maxSamples) { this.getYMax({value}) this.getVScale() this.Points.push(value) //интересуют Int this.Samples ++; this.TextPrc = `${((this.Samples * 100)/this.maxSamples) | 0} %` } }, getSVGPoints(){ //теперь создаю строку для Path let predY = 0 let path = this.Points.reduce((str, item)=>{ let dY = (item - predY) * this.scales.h predY = item return str + `l ${this.scales.w} ${dY} ` },'M 0 0 ') path +=`L ${this.Points.length * this.scales.w} 0 `// Z` контур можно не замыкать return path }, }, computed:{ getPath(){ this.addValue({value:this.Point.value})//Добавить новую точку return this.getSVGPoints()//this.d - построить SVG PATH } } } </script> <style scoped> .wrapper { width: 400px;/* размеры задаются в стилях приложения*/ height: 100px;/*встроенные стили (по умолчанию) перекрываются стилями приложения*/ font-size: 4rem; font-weight: 600; border-left: 1px gray solid; border-right: 1px gray solid; overflow: hidden; } .wrapper__content path { opacity: 0.5; fill: lightgreen; stroke: green; stroke-width: 1; } .wrapper__content text { opacity: 0.5; } </style>
Вызов из родительского компонента и передача параметров
Для работы с компонентом требуется его импортировать в родительский компонент
import Progresser from "./components/Progresser"
и объявить в секции
components: {Progresser }
В шаблон родительского компонента, компонент-индикатор progresser вставляется следующей конструкцией:
<progresser class="progresser" :maxSamples = "SamplesInProgresser" :Point = "Point" ></progresser>
Через класс «progreser» в первую очередь задаются размеры блока у индикатора. В props компонента передаются maxSamples (макс кол-во точек в графике) из переменной родителя SamplesInProgresser, и в props Point передаётся очередная точка (в виде объекта) из переменной-объекта Point родителя. Point родителя рассчитывается в функции update, и представляет собой увеличивающиеся случайные числа. Получаю такую картинку:
Исходник родителя App.vue<template> <div> <progresser class="progresser" :maxSamples = "SamplesInProgresser" :Point = "Point" ></progresser> </div> </template> <script> import Progresser from "./components/Progresser" export default { name: 'app', data(){ return { SamplesInProgresser:400,//макс кол-во точек Point:{value:0},//"точка" index:0, //контроль кол-ва переданных точек TimeM:100 //база для генератора случайных чисел } }, created: function () { this.update() }, methods:{ update(){ if (this.index < this.SamplesInProgresser) { this.index++; this.Point = {value:(this.TimeM*Math.random() | 0)} this.TimeM *= 1.01 setTimeout(this.update, 0) } } }, components: { Progresser } } </script> <style> #app { font-family: 'Avenir', Helvetica, Arial, sans-serif; margin-top: 60px; } /*через стили задаю размеры индикатора загрузки*/ .progresser { width: 300px; height: 80px; } </style>
-
Применение компонента в SPA
Приступаем к тому, ради чего всё затевалось. И так, у меня есть асинхронные операции по загрузке из базы записей о неких личностях. Время выполнения асинхронной операции заранее неизвестно. Я буду измерять время выполнения банальным способом, с помощью new Date().getTime() до и после операции, и полученную разность времени буду передавать в компонент. Естественно, индикатор будет встроен в блок, который будет появляться на этапе загрузки, и затенять собой таблицу для которой загружаются данные.
async getCandidatesData(){ ... this.LoadRecords = true //сообщаю что началась загрузка, чтобы поверх контента появился блок с индикатором ... this.SamplesInProgresser = uris.length //сообщаю компоненту сколько записей буду загружать ... for (let item of uris) {//в uris массив URL которые надо загрузить try { const start = new Date().getTime()//время до операции candidate = await this.$store.dispatch('GET_CANDIDATE', item) const stop = new Date().getTime()//время после выполнения this.Point = {value:(stop-start)}//передаю разность в Point ...
В data компонента-родителя прописываю что касается индикации загрузки:
data (){ return { ... //Индикатор загрузки LoadRecords:false, SamplesInProgresser:400, Point:{value:0} }
И в шаблоне:
<!-- индикатор загрузки записей--> <div class="wait_loading" v-show="LoadRecords"> <progresser class="progresser" :maxSamples = "SamplesInProgresser" :Point = "Point" ></progresser> </div>
Выводы
Как и прогнозировалось, ничего сложного. До какого-то момента можно относится к SVG как обычным HTML-тегам, со своей спецификой. SVG — мощный инструмент который я теперь чаще буду использовать в своей работе для визуализации данных
Ссылки
Исходный код Индикатора загрузки
Статья по svg-path