- PVSM.RU - https://www.pvsm.ru -

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

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

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

image

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

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

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

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

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

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

2. Каркас

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

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 [10]. Импортируем наш 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], так как начало координат будет по центру нашего радара. Для нахождения вершин В и С воспользуемся функцией из отличного туториала [11] по гексагональным сеткам. На вход функция получает размер сектора и направление, а возвращает строку с координатами '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 [12]. Если у вас есть идеи, как это можно сделать элегантней, напишите об этом в комментариях.

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. Хранение данных

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

Создадим файл 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 [15]. Мы используем имя переменной $radar. Директива $ [16] дает понять компилятору Svelte, что наше выражение является хранилищем, и он создает подписку на изменения. Переменная direction хранит индекс текущей итерации, по нему мы будем задавать направление нашего сектора. Выражение (sector.name) указывает svelte на id объекта в итерации [17]. Аналог 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) значение, отличное от нуля, то должен получится такой результат:
Полная жизнь на Svelte - 7

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: [18]

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>

Радар теперь реагирует на клики и перетаскивания.
Полная жизнь на Svelte - 8

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 [19], исходный код доступен в gitlab [20].

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

Автор: Alexander Zinchenko

Источник [21]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/334693

Ссылки в тексте:

[1] Полная Ж: https://www.litres.ru/radislav-gandapas/polnaya-zh-zhizn-kak-biznes-proekt

[2] здесь: https://svelte.dev/repl/3afce9affec54b49919fa3c76949904d?version=3.12.1

[3] REPL: https://svelte.dev/repl/

[4] webpack: https://github.com/sveltejs/template-webpack

[5] rollup: https://github.com/sveltejs/template

[6] codesandbox: https://codesandbox.io/s/svelte

[7] svelte-vscode: https://marketplace.visualstudio.com/items?itemName=JamesBirtles.svelte-vscode

[8] style: https://svelte.dev/tutorial/styling

[9] :global(): https://svelte.dev/docs#style

[10] script: https://svelte.dev/docs#script

[11] отличного туториала: https://www.redblobgames.com/grids/hexagons/#basics

[12] #each: https://svelte.dev/tutorial/each-blocks

[13] кастомный стор: https://svelte.dev/tutorial/custom-stores

[14] хранилище Svelte: https://svelte.dev/tutorial/writable-stores

[15] #each: https://svelte.dev/docs#each

[16] Директива $: https://svelte.dev/tutorial/store-bindings

[17] id объекта в итерации: https://svelte.dev/tutorial/keyed-each-blocks

[18] use:: https://svelte.dev/docs#use_action

[19] life-checkup: https://life-checkup.web.app

[20] gitlab: https://gitlab.com/az67128/svelte-life

[21] Источник: https://habr.com/ru/post/458974/?utm_source=habrahabr&utm_medium=rss&utm_campaign=458974