[Из песочницы] SVG индикатор загрузки на Vue.js

Привет! Учусь на front-end, и параллельно, в учебном проекте, разрабатываю SPA на Vue.js для back-end, который собирает данные от поискового бота. Бот нарабатывает от 0 до 500 записей, и я должен их: загрузить, отсортировать по заданным критериям, показать в таблице.

Ни back-end ни бот, сортировать данные не умеют, поэтому мне приходятся загружать все данные и обрабатывать их на стороне браузера. Сортировка происходит очень быстро, а вот скорость загрузки, зависит о коннекта, и указанные 500 записей могут загружаться от 10 до 40 секунд.

Поначалу, при загрузке, я показывал спинер, недостаток которого — пользователь не знает когда закончится загрузка. В моём случае заранее известно количество записей которые отыскал бот, поэтому можно показать сколько % записей загружено.

Чтобы скрасить пользователю ожидание, я решил показать ему процесс загрузки:

Вот анимация результата, к которому я стремился и получил:

… по-моему, получилось забавно.

В статье я покажу как продвигался к результату шаг за шагом. Графики функций в браузере я до селе не рисовал, поэтому разработка индикатора принесла мне простые, но новые знания о применении SVG и Vue.

Canvas я применял в простой игре-змейке на JS, а SVG, в одном проекте, я просто вставлял на страницу в теге object и заметил, что при масштабировании, SVG-картинки всегда сохраняли чёткость (на то он и вектор), а у Canvas наблюдалось размытие изображения. На основании этого наблюдения, я решил рисовать график с помощью SVG, ведь надо когда-то начинать.

С учётом выбранного фрймворка Vue, и выбранного способа формирования изображения с помощью SVG, составил себе следующий план работ:

Создание заготовки проекта

У меня установлен vue cli. Для создания нового проекта, в командной строке ввожу vue create loadprogresser, настройки проекта выбираю default создаётся новый vue-проект с названием loadprogresser, дальше убираю из него лишнее:



Поиск и изучение информации по теме применения SVG совместно с Vue

Отличный сайт с полезной инфой по HTML, CSS и SVG css.yoksel.ru Хороший пример с SVG размещён в документации самого Vue SVG-график Example и по такой ссылочке. На основе этих материалов родился минимальный шаблон компонента с SVG с которого я и стартую:



Эксперименты с формированием и изменением SVG в контексте Vue


SVG Прямоугольник rect

rect — прямоугольник, самая простая фигура. Создаю svg с размерами 100×100 px, и рисую прямоугольник rect с начальными координатами 25:25 и размерами 50×50 px, по умолчанию цвет заливки чёрный (нет стилизации)


SVG стилизация и псевдоэлемент hover:

Попробую стилизовать прямоугольник rect в svg. Для этого к svg добавляю класс «sample», в секции style vue-файла добавляю стили .sample rect (раскрашиваю прямугольник rect жёлтым цветом) и .sample rect: hover который стилизует элемент rect при наведении на него курсора мыши:


Исходник

                      

                      
            


Реализация на JSfiddle

Вывод: svg отлично встраивается в template vue-файла и стилизуется прописанными стилями. Начало положено!


SVG path как основа индикатора

В этом разделе я заменю rect на 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×50 px начинающийся из коодинат 0:0. С помощью стиля «path» фигуре придаётся жёлтый цвет заполнения и серая рамка в 1 px.


Исходник жёлтого PATH
    
          
          


Создание прототипа индикатора загрузки

В минимальном варианте, я сделал простую диаграмму. В шаблоне вставлен svg-контейнер высотой 100 px, шириной 400 px, внутри размещён тег 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 (приращение на 1 px), в дальнейшем, по оси 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 и узнать размеры контейнера


  1. ref — получение информации о элементе DOM

    DIV-ву контейнеру (в который вставлен svg) я добавляю класс wrapper чтобы передать ширину и высоту через стили. И чтобы svg занял всё пространство контейнера, задал его высоту и ширину 100%. RECT, в свою очередь, тоже займёт всё пространство контейнера и будет фоном для PATH

    Для того чтобы найти мой DIV-контейнер в виртуальном DOM Vue, добавляю атрибут ref и присваиваю ему имя по которому буду осуществлять поиск ref="loadprogresser". В хуке жизненного цикла mounted я вызову метод getScales (), в котором, строкой const {width, height} = this.$refs.loadprogresser.getBoundingClientRect() узнаю ширину и высоту DIV-элемента после его появления в DOM.

    Дальше простые расчёты приращения по оси Х зависящего от ширины контейнера и кол-ва точек которые хотим в него уместить. Масштаб по оси Y пересчитывается каждый раз при нахождении максимума в переданном значении.


  2. transform — изменение системы координат

    На этом этапе я замечаю, что надо бы изменить систему координат так, чтобы координата 0:0 начиналась из нижнего левого угла, и ось Y росла бы вверх, а не вниз. Можно, конечно, сделать рассчёты для каждой точки, но в SVG есть атрибут transform, позволяющий трансформировать координаты.

    В моём случае требуется применять к Y координатам масштаб -1 (чтобы значения Y откладывались вверх), и сместить начало координат на минус высоту контейнера. Так как высота контейнера может быть любой (задаётся через стили), то пришлось формировать строку трансформации координат в хуке mounted таким кодом: this.transform = `scale( 1, -1) translate(0,${-this.wrapHeight})`

    Но сама по себе трансформация применённая к PATH не сработает, для этого надо обернуть PATH в группу (тег g) к которой и применить трансформации координат:

    
        
    

    В итоге координаты правильно развернулись, индикатор загрузки стал ближе к задуманному дизайну


  3. SVG text и центрирование текста

    Текст нужен для вывода % загрузки. Размещение текста в центре по вертикали и горизонтали в SVG довольно просто организовать (по сравнению с HTML/CSS), на помощь приходят атрибуты (сразу прописываю значения) dominant-baseline=«central» и text-anchor=«middle»

    Текст в SVG выводится соответствующим тегом:


    {{TextPrc}}

    где 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)

            //шаблон
            ...
            
            ...
            //свойства компонента
            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





                      
            


Вызов из родительского компонента и передача параметров

Для работы с компонентом требуется его импортировать в родительский компонент
import Progresser from "./components/Progresser"
и объявить в секции
components: {Progresser }

В шаблон родительского компонета, компонент-индикатор progresser вставляется следующей конструкцией:

             
        

Через класс «progreser» в первую очередь задаются размеры блока у индикатора. В props компонента передаются maxSamples (макс кол-во точек в графике) из переменной родителя SamplesInProgresser, и в props Point передаётся очередная точка (в виде объекта) из переменной-объекта Point родителя. Point родителя расчитывается в функции update, и представляет собой увеличивающиеся случайные числа. Получаю такую картинку:


umrby4sqxabfzgz_ng1lnk6xgcy.gif
Исходник родителя App.vue

                      



                      
            


Применение компонента в 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}           
    }
        

И в шаблоне:



Как и прогнозировалось, ничего сложного. До какого-то момента можно относится к SVG как обычным HTML-тегам, со своей спецификой. SVG — мощный инструмент который я теперь чаще буду использовать в своей работе для визуализации данных

© Habrahabr.ru