SVG индикатор загрузки на Vue.js

от автора

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

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

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

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

  1. цифрами — сколько % записей уже загружено
  2. графиком — время загрузки каждой записи
  3. заполнением — % загрузки. Так как график по мере загрузки заполняет прямоугольный блок, видно, какую часть блока осталось заполнить

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

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

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

Выбор способа отрисовки Canvas или SVG

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

План работ

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

  1. Поиск и изучение информации по теме применения SVG совместно с Vue
  2. Эксперименты с формированием и изменением SVG в контексте Vue
  3. Создание прототипа индикатора загрузки
  4. Выделение индикатора загрузки в отдельный Vue-компонент
  5. Применение компонента в SPA

Приступаю к реализации

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

    У меня установлен 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

  2. Поиск и изучение информации по теме применения 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> 

  3. Эксперименты с формированием и изменением 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> 

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

    В минимальном варианте, я сделал простую диаграмму. В шаблоне вставлен 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 и узнать размеры контейнера

    1. 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 пересчитывается каждый раз при нахождении максимума в переданном значении.

    2. 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>

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

    3. 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.

      Базовые вещи по теме отработаны, теперь надо выделить прототип индикатора в отдельный компонент.

  5. Выделение индикатора загрузки в отдельный 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>                                    

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


ссылка на оригинал статьи https://habr.com/ru/post/460399/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *