Полная жизнь на Svelte

У Радислава Гандапаса есть отличная книга Полная Ж. В ней говорится о том, как оценить направления своей жизни, и как разработать план развития.
Мне захотелось создать инструмент, который будет в моем смартфоне и поможет составить мой радар.
image


1. Подготовка

Исходный код туториала и демо можно посмотреть здесь.

Этот проект небольшой, поэтому писать мы будем сразу в REPL, онлайн редакторе svelte. Если вам по душе локальная разработка, то можете воспользоваться webpack или rollup шаблонами svelte.

Как альтернативу локальной разработке могу посоветовать онлайн инструмент codesandbox.

Если вы используете VScode, то рекомендую установить плагин svelte-vscode

Итак, открываем REPL и начинаем


2. Каркас

Сейчас у нас есть файл App.svelte, это точка входа в приложение. Компоненты Svelte стилизуются в теге style, как в обычном html. При этом вы получаете изоляцию стилей на уровне компонента. Если необходимо добавить глобальные стили, которые будут доступны «снаружи» объекта, то нужно воспользоваться директивой : global (). Добавим стили и создадим контейнер для нашего приложения.


App.svelte


Создадим компонент Radar.svelte. Это будет SVG элемент, в котором мы будем рисовать наше колесо.


Radar.svelte

Javascript код в компоненте Svelte помещается в тег script. Импортируем наш Radar.svelte в App.svelte и отрисуем его.


App.svelte



Сам радар будет состоять из секторов, соответствующих жизненным аспектам. Каждый сектор имеет свой индекс

image

Каждый сектор состоит из сетки, которая, в свою очередь, является сектором с меньшим размером.

image

Для отрисовки сектора нам нужно знать координаты трех вершин.

image

Вершина А всегда с координатами [0, 0], так как начало координат будет по центру нашего радара. Для нахождения вершин В и С воспользуемся функцией из отличного туториала по гексагональным сеткам. На вход функция получает размер сектора и направление, а возвращает строку с координатами 'x, y'.
Создадим файл getHexCorner.js, куда поместим нашу функцию getHexCorner (size, direction)


getHexCorner.js
export default function getHexCorner(size, direction) {
  const angleDeg = 60 * direction - 30;
  const angleRad = (Math.PI / 180) * angleDeg;
  return `${size * Math.cos(angleRad)},${size * Math.sin(angleRad)}`;
}

Теперь создадим компонент сектора Sector.svelte, который рисует сетку. Нам нужен цикл из 10 шагов. В теле компонента svelte не умеет реализовывать цикл for, поэтому я просто сделал массив grid, по которому буду итерировать в директиве #each. Если у вас есть идеи, как это можно сделать элегантней, напишите об этом в комментариях.


Sector.svelte




{#each grid as gridValue, i}
  
{/each}

Импортируем и отрисуем сектор в компоненте Radar.svelte.


Radar.svelte


    

Теперь наше приложение отображает 1 сектор.
image


3. Хранение данных

Чтобы отрисовать весь радар, необходимо знать перечень секторов. Поэтому займемся созданием хранилища состояния. Мы будем использовать кастомный стор, в котором реализуем логику обновления состояния. Вообще, это обычное хранилище Svelte, которое завернуто в функцию. Это позволяет защитить хранилище от изменений, предоставив набор доступных действий. Мне нравится этот подход тем, что структура данных и логика работы с ними находятся в одном месте.
Создадим файл store.js
Нам потребуются два хранилища:


  • radar для хранения текущих значений
  • activeSector для хранения активного сектора, если происходят события touchmove и mousemove.


store.js
import { writable } from "svelte/store"; 
const defaultStore = ["hobby", "friendship", "health", "job", "love", "rich"];

function Radar() {
  /* инициализируем хранилище с начальным состоянием */
  const { subscribe, update } = writable(defaultStore.map(item=>({name:item, value:0})));
  /* возвращаем объект с функцией подписки и доступными действиями */
  return {
    subscribe,
    set: (id, value) =>
      update(store =>
        store.map(item => (item.name === id ? { ...item, value } : item))
      )
  };
}
export const radar = Radar(); 
export const activeSector = writable(null); 

Теперь импортируем созданный стор в компонент Radar.svelte и добавим логику отрисовки полного радара.


Radar.svelte



  {#each $radar as sector, direction (sector.name)}
    
  {/each}

Немного тонкостей директивы #each. Мы используем имя переменной $radar. Директива $ дает понять компилятору Svelte, что наше выражение является хранилищем, и он создает подписку на изменения. Переменная direction хранит индекс текущей итерации, по нему мы будем задавать направление нашего сектора. Выражение (sector.name) указывает svelte на id объекта в итерации. Аналог key в React.

Сейчас наша сетка выглядит вот так
image

Осталось подготовить сектор к работе с событиями нажатия и перетаскивания.
Событие touchmove, в отличие от mousemove, срабатывает только на элементе, на котором началось. Поэтому мы не сможем отловить момент, когда указатель переместился на другой сектор. Для решения этой проблемы в разметке элемента мы будем хранить текущее имя (name) сектора и его значение (value). В момент события будем определять, какой сектор находится под курсором, и изменять его значение.

Обратите внимание, что Svelte умеет разворачивать конструкцию {varName} в varName={varName}. Это очень упрощает прокидывание свойств.


Sector.svelte




{#each grid as gridValue, i}
  = gridValue ? name : ''}
    {name}
    value={gridValue} />
  />
{/each}

Если мы добавим в нашем сторе (store.js) значение, отличное от нуля, то должен получится такой результат:
kktn_o68cgbftxtsqnfz7w716du.png


4. События

Пришло время вдохнуть жизнь в наш радар, создадим обработчик, который на вход принимает ноду, а внутри производит обработку событий касания и мыши.


handleRadar.js
import { radar, activeSector } from "./store.js";
/* директива get нужна для получения текущего значения хранилища без подписки на само хранилище */
import { get } from "svelte/store"; 

export default function handleRadar(node) {
  const getRadarElementAtPoint = e => {
        /* определяем тип события: касание или мышь */ 
    const event = e.touches ? e.touches[0] : e;
    const element = document.elementFromPoint(event.pageX, event.pageY);
        /* получаем имя и значение сектора из html разметки */
    const score = element.getAttribute("value");
    const id = element.getAttribute("name");
    return { id, score, type: event.type };
  };
  const start = e => {
        /* получаем элемент радара из активного сектора */
    const { id } = getRadarElementAtPoint(e);
        /* устанавливаем текущий активный сектор */
    activeSector.set(id);
  };
  const end = () => {
        /* сбрасываем активный сектор */
    activeSector.set(null);
  };
  const move = e => {
        /* тротлинг через requestAnimationFrame поможет избежать лагов при активном перемещении */
    window.requestAnimationFrame(() => {
      const { id, score, type } = getRadarElementAtPoint(e);
            /* проверяем, что у нас есть активный сектор, т.е. движение началось внутри радара, и это не клик */
      if (!id || (id !== get(activeSector) && type !== "click") || !score) return;
            /* обновляем состояние радара */
      radar.set(id, score);
    });
  };
  /* регистрируем обработчики */
  node.addEventListener("mousedown", start);
  node.addEventListener("touchstart", start);
  node.addEventListener("mouseup", end);
  node.addEventListener("touchend", end);
  node.addEventListener("mousemove", move);
  node.addEventListener("touchmove", move);
  node.addEventListener("touch", move);
  node.addEventListener("click", move);

    /* возвращаем объект с функцией destroy, которая произведет отписку от событий при удалении компонента из DOM */
  return {
    destroy() {
      node.removeEventListener("mousedown", start);
      node.removeEventListener("touchstart", start);
      node.removeEventListener("mouseup", end);
      node.removeEventListener("touchend", end);
      node.removeEventListener("mousemove", move);
      node.removeEventListener("touchmove", move);
      node.removeEventListener("touch", move);
      node.removeEventListener("click", move);
    }
  };
}

Теперь просто добавим наш обработчик в svg элемент радара через директиву use:


Radar.svelte



  {#each $radar as sector, direction (sector.name)}
    
  {/each}

Радар теперь реагирует на клики и перетаскивания.
1-vpkvqnfolcdalsviozvy0cn9i.gif


6. Финальные штрихи

Добавим подписи для секторов и описание


Sector.svelte




{#each grid as gridValue, i}
  = gridValue ? name : ''}
    {name}
    value={gridValue} />
{/each}


  
    {radarTranslation[name]}
  
  
    {value}
  

Радар должен выглядеть так.
image


5. Бонус

Я немного расширил функционал радара, добавил хранение данных в localStorage и составление плана действий. Вы можете попробовать приложение life-checkup, исходный код доступен в gitlab

mipqd6kwnnzflsvo3d1txviisro.png

© Habrahabr.ru