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

  • Tutorial

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


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


image


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


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


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


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


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


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


2. Каркас


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


App.svelte
<style>
  :global(body) {
    height: 100%; 
    overscroll-behavior: none; /* отключает pull to refresh*/
    user-select: none; /* Отключает выделение в тач интерфейсах */
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
      Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
    background: rgb(35, 41, 37);
  }
  :global(html) {
    height: 100%;
  }
  .container {
    flex-grow: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    height: 100%;
  }
</style>

<div class="container">
</div>

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


Radar.svelte
<svg viewBox="-115 -110 230 220">
</svg>

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


App.svelte
<script>
import Radar from './Radar.svelte' /* импортируем наш компонент */
</script>
<style>
  :global(body) {
    height: 100%; 
    overscroll-behavior: none; 
    user-select: none; 
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
      Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
    background: rgb(35, 41, 37);
  }
  :global(html) {
    height: 100%;
  }
  .container {
    flex-grow: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    height: 100%;
  }
</style>

<div class="container">
    <Radar/> <!-- отрисовываем компонент Radar -->
</div>

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


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
<script>
  import getHexCorner from "./getHexCorner.js";
  export let direction = 0;
  const grid = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
</script>

<style>
  polygon {
    fill: #293038;
    stroke: #424a54;
  }
</style>

{#each grid as gridValue, i}
  <polygon
    points={`${getHexCorner(gridValue * 10, direction)}, ${getHexCorner(gridValue * 10, direction + 1)}, 0, 0`}
    strokeLinejoin="miter-clip"
    stroke-dasharray="4"
    stroke-width="0.5" />
{/each}

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


Radar.svelte
<script>
import Sector from './Sector.svelte';
</script>
<svg viewBox="-115 -110 230 220">
    <Sector/>
</svg>

Теперь наше приложение отображает 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
<script>
  import { radar } from "./store.js";
  import Sector from "./Sector.svelte";
</script>

<svg viewBox="-115 -110 230 220">
  {#each $radar as sector, direction (sector.name)}
    <Sector {...sector} {direction} />
  {/each}
</svg>

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


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


image


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

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


Sector.svelte
<script>
  import getHexCorner from "./getHexCorner.js";
  export let direction = 0;
  export let name;
  export let value;

  const grid = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
</script>

<style>
  polygon {
    fill: #293038;
    stroke: #424a54;
  }
  .rich {
    fill: #469573;
  }
  .hobby {
    fill: #7c3f7a;
  }
  .friendship {
    fill: #5c6bc0;
  }
  .health {
    fill: #e5b744;
  }
  .job {
    fill: #e16838;
  }
  .love {
    fill: #e23f45;
  }
</style>

{#each grid as gridValue, i}
  <polygon
    points={`${getHexCorner(gridValue * 10, direction)}, ${getHexCorner(gridValue * 10, direction + 1)}, 0, 0`}
    strokeLinejoin="miter-clip"
    stroke-dasharray="4"
    stroke-width="0.5"
    class={value >= gridValue ? name : ''}
    {name}
    value={gridValue} />
  />
{/each}

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


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
<script>
  import { radar } from "./store.js";
  import Sector from "./Sector.svelte";
  import handleRadar from "./handleRadar.js";
</script>

<svg viewBox="-115 -110 230 220" use:handleRadar>
  {#each $radar as sector, direction (sector.name)}
    <Sector {...sector} {direction} />
  {/each}
</svg>

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


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


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


Sector.svelte
<script>
  import getHexCorner from "./getHexCorner.js";
  export let name;
  export let value;
  export let direction;

  const grid = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];

  const flip = direction === 2 || direction === 1;

  const radarTranslation = {
    hobby: "ХОББИ",
    friendship: "ДРУЖБА",
    health: "ЗДОРОВЬЕ",
    job: "РАБОТА",
    love: "ЛЮБОВЬ",
    rich: "БЛАГОСОСТОЯНИЕ"
  };
</script>

<style>
  polygon {
    fill: #293038;
    stroke: #424a54;
  }
  text {
    font-size: 8px;
    fill: white;
  }
  .value {
    font-weight: bold;
    font-size: 12px;
  }
  .rich {
    fill: #469573;
  }
  .hobby {
    fill: #7c3f7a;
  }
  .friendship {
    fill: #5c6bc0;
  }
  .health {
    fill: #e5b744;
  }
  .job {
    fill: #e16838;
  }
  .love {
    fill: #e23f45;
  }
</style>

{#each grid as gridValue, i}
  <polygon
    points={`${getHexCorner(gridValue * 10, direction)}, ${getHexCorner(gridValue * 10, direction + 1)}, 0, 0`}
    strokeLinejoin="miter-clip"
    stroke-dasharray="4"
    stroke-width="0.5"
    class={value >= gridValue ? name : ''}
    {name}
    value={gridValue} />
{/each}

<g
  transform={`translate(${getHexCorner(105, flip ? direction + 1 : direction)}) rotate(${direction * 60 + (flip ? -90 : 90)})`}>
  <text x="50" y={flip ? 5 : 0} text-anchor="middle">
    {radarTranslation[name]}
  </text>
  <text x="50" y={flip ? 18 : -10} text-anchor="middle" class="value">
    {value}
  </text>

</g>

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


image


5. Бонус


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


Комментарии 8

    +1
    Если у вас есть идеи, как это можно сделать элегантней, напишите об этом в комментариях.

    Может и не элегантнее, но просто иной подход:


    {#each new Array(10) as _,gridValue}
      <polygon
        points={`${getHexCorner((10-gridValue) * 10, direction)}, ${getHexCorner((10-gridValue) * 10, direction + 1)}, 0, 0`}
        strokeLinejoin="miter-clip"
        stroke-dasharray="4"
        stroke-width="0.5" />
    {/each}
      +1

      Там еще тонкость в том, что индексы в обратном порядке, чтобы правильно слои расположились

        +1

        Поэтому и 10-key =)

          +1

          точно)

        +2

        А вы не хотите вынести эту портянку в отдельную функцию, а не фигачить логику вперемешку с вёрсткой?

        +2
        Сделайте такую прогу для ценностей Рокича. В отличие от остальных психологов, он тестировал свою систему на нескольких тысячах людей.
          0
          Добрый день, как раз искал подобный виджет, но для несколько иной задачи, не связанной со здоровьем, и наткнулся на эту статью, хотелось бы узнать, можно ли найти пакет в npm и какая лицензия на исходный код (в гитлабе лицензия не указана) и политика контрибутинга?
            +1

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

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

          Самое читаемое