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

в 11:34, , рубрики: javascript, svg, tutorial, UI, vue.js, vuejs, визуализация данных

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

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

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

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

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

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

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

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

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

    Было Стало
    SVG индикатор загрузки на Vue.js - 2

    Структура проекта по default

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

    Структура после «уборки»

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

    Приветствие от 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, и представляет собой увеличивающиеся случайные числа. Получаю такую картинку:

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

    Исходник родителя 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

Автор: GerasimGerasimov

Источник


* - обязательные к заполнению поля