Skip to content

Раскладки ​

Алгоритм раскладки вычисляет позиции узлов свёрнутого графа. Два алгоритма встроены в пакет, третий лежит за отдельным подпутём, и любой объект с методом run может их заменить.

Показать исходник
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AssetMap } from 'vue-asset-map'
import { dagreLayout } from 'vue-asset-map/layout-dagre'
import { useSiteTheme } from '../theme/useSiteTheme'
import { useSiteLocale } from '../theme/useSiteLocale'
import { entities, links, views, statusColors, kindIcons } from './fixture'

const theme = useSiteTheme()
const locale = useSiteLocale()
const ENGINES = ['radial', 'manual', 'dagre'] as const
const engineIndex = ref(0)
const engineName = computed(() => ENGINES[engineIndex.value % ENGINES.length])

// Engine options are engine-specific and pass through untouched. `dagre` is
// passed as an instance rather than by name: it lives behind an optional
// dependency and is never registered under the built-in names.
const layout = computed(() => {
  switch (engineName.value) {
    case 'radial':
      return { engine: 'radial', options: { radius: 240, ringGap: 190 } }
    case 'dagre':
      return { engine: dagreLayout, options: { rankdir: 'TB', ranksep: 90 } }
    default:
      return { engine: 'manual', options: { columns: 6 } }
  }
})

function nextEngine(): void {
  engineIndex.value += 1
}
</script>

<template>
  <AssetMap
    class="demo-map"
    view="physical"
    :entities="entities"
    :links="links"
    :views="views"
    :layout="layout"
    :theme="theme"
    :locale="locale"
    :theme-config="{ statusColors, kindIcons }"
    auto-fit-view
  >
    <template #toolbar-right>
      <button type="button" class="demo-toolbar-button" @click="nextEngine">Engine: {{ engineName }}</button>
    </template>
  </AssetMap>
</template>

Встроенные алгоритмы ​

manual ​

Закреплённые узлы остаются на своих местах, остальные выстраиваются в детерминированную сетку. Подходит для случая, когда позиции приходят с бэкенда и координаты нужно сохранить, а не пересчитать.

ts
{ engine: 'manual', options: { columns: 8, columnGap: 200, rowGap: 140, origin: { x: 0, y: 0 } } }

columnGap и rowGap по умолчанию равны самому крупному узлу плюс 48 px, поэтому карточки разных размеров не наезжают друг на друга.

radial ​

Концентрические кольца по расстоянию от корня в рёбрах, поэтому номер кольца читается как число хопов.

ts
{ engine: 'radial', options: { rootId: 'internet', radius: 220, ringGap: 180, startAngle: -Math.PI / 2 } }

Без rootId центром становится узел наибольшей степени. Компоненты, недостижимые из корня, не выбрасываются: они получают кольцо за самым дальним связанным, поэтому изоляты остаются видимыми.

dagre ​

Ярусная раскладка, за своим подпутём, чтобы опциональная зависимость не попадала в основной бандл:

bash
npm install @dagrejs/dagre
vue
<script setup lang="ts">
import { AssetMap } from 'vue-asset-map'
import { dagreLayout } from 'vue-asset-map/layout-dagre'
</script>

<template>
  <AssetMap
    :entities="entities"
    :links="links"
    :layout="{ engine: dagreLayout, options: { rankdir: 'LR', ranksep: 120 } }"
  />
</template>

Опции проходят насквозь: rankdir, align, nodesep, ranksep, edgesep, ranker.

Приоритет позиций ​

Конкурируют три источника, в таком порядке:

  1. позиция, полученная перетаскиванием узла,
  2. entity.position из ваших данных,
  3. результат алгоритма.

Первые два передаются алгоритму как pinned, и он не меняет их положение. Dagre не умеет фиксировать узлы, поэтому поверх его результата закреплённые позиции проставляются заново: снаружи поведение такое же, как у остальных алгоритмов.

Как запомнить расстановку ​

Перетащенные позиции уходят наружу через v-model:positions — обычный объект с ключами по id сущностей. Привяжите его, сохраните то, что выходит, верните при следующем открытии — и карта откроется ровно такой, какой её оставили.

vue
<script setup lang="ts">
import { ref, watch } from 'vue'

const positions = ref(JSON.parse(localStorage.getItem('layout') ?? '{}'))
watch(positions, (next) => localStorage.setItem('layout', JSON.stringify(next)), { deep: true })
</script>

<template>
  <AssetMap v-model:positions="positions" :entities="entities" :links="links" />
</template>

В нём лежат только осознанные перемещения, а не координаты всех узлов, — именно поэтому сохранённая расстановка переживает изменение данных. Сущность, добавленная через неделю, будет разложена алгоритмом, а не встанет туда, что запомнил старый файл; исчезнувшая оставит после себя безобидный неиспользуемый ключ.

Обычный объект, а не Map: это то, что сериализуют, а JSON.stringify превращает Map в {}.

Сама карта никуда не пишет. Где живёт раскладка — в localStorage, в профиле пользователя, в URL — решает приложение, ровно как и со всем остальным состоянием, которое карта отдаёт наружу.

Сохранение по одному узлу ​

node-move приходит на каждый узел в момент, когда перетаскивание закончено, и несёт сущность вместе с id:

vue
<AssetMap
  :entities="entities"
  :links="links"
  @node-move="({ id, position }) => api.patchAsset(id, position)"
/>

Сохранить один сдвинутый актив — это другой запрос, чем сохранить всю раскладку, и так не приходится сравнивать два объекта, чтобы понять, какой именно узел переехал.

Вся картина целиком ​

getPositions() возвращает позиции всех видимых узлов, включая посчитанные алгоритмом, — снимок для экспорта или превью, в отличие от расстановки, которую выбрал человек:

ts
const map = useTemplateRef<AssetMapExposed>('map')
const snapshot = map.value?.getPositions()

Ключи — id узлов, поэтому свёрнутая группа отчитывается своим собственным id: её участников на экране нет, и позиции у них тоже нет. Результат — копия, её можно спокойно хранить и править.

resetPositions() забывает всё расставленное руками и возвращает карту алгоритму.

Свой алгоритм ​

ts
import type { LayoutEngine } from 'vue-asset-map'
import { applyPinned, throwIfAborted } from 'vue-asset-map'

export const columnsLayout: LayoutEngine<{ gap?: number }> = {
  name: 'columns',
  async run(graph, options, signal) {
    throwIfAborted(signal, 'columns')

    const gap = options?.gap ?? 160
    const positions = new Map<string, { x: number; y: number }>()

    graph.nodes.forEach((node, index) => {
      positions.set(node.id, { x: index * gap, y: 0 })
    })

    return applyPinned(positions, graph.nodes)
  },
}
vue
<AssetMap :entities="entities" :links="links" :layout="{ engine: columnsLayout, options: { gap: 200 } }" />

Позиции — это левые верхние углы в пикселях холста, по id узла. Узлы, которых нет в возвращённой карте, остаются на своих местах.

Обрабатывайте signal. Каждый запуск отменяет предыдущий, поэтому пользователь, трижды переключивший представление, увидит результат только последней раскладки. Для этого экспортируются throwIfAborted и yieldAndCheck. Устаревший результат отбрасывается, даже если сторонний алгоритм игнорирует сигнал, но алгоритм без проверок будет впустую жечь CPU на долгом расчёте.

Регистрация по имени ​

vue
<AssetMap :entities="entities" :links="links" :layout="{ engine: 'columns' }" />

Неизвестное имя алгоритма не фатально: карта откатывается на manual и сообщает об этом событием error, потому что имя может прийти из данных представления, то есть снаружи.

ts
import { builtinLayoutEngines, resolveLayoutEngine } from 'vue-asset-map'

resolveLayoutEngine('radial') // radialLayout
resolveLayoutEngine('columns', { columns: columnsLayout })

Ручной пересчёт ​

ts
const map = useTemplateRef<AssetMapExposed>('map')
await map.value?.relayout()

layout-start, layout-done и error сообщают о ходе запуска.

Автоподгонка ​

Без стартовой позиции непривязанный v-model:viewport по умолчанию равен { x: 0, y: 0, zoom: 1 }, поэтому то, что раскладка поставила в начало координат, оказывается в левом верхнем углу, а не по центру.

vue
<AssetMap :entities="entities" :links="links" auto-fit-view />

autoFitView подгоняет холст под содержимое, как только досчитается первая раскладка, и ещё раз — при смене представления.

На всякое изменение он намеренно не срабатывает. Свёртка группы, раскрытие поддерева, сужение фильтра — всё это меняет набор видимых узлов, и подгонка на них сдвигала бы вид, пока человек работает с одним углом графа: карта прыгает, и место теряется. Смена представления — другое дело: это другой граф, а прежний вид описывает раскладку, которой больше нет.

Момент подгонки выбирается по сигналу Vue Flow о том, что размеры узлов уже измерены, а не по таймауту. Поэтому она не центрирует карту, пока раскладка не досчитана. Явный вызов fitView() всё это не касается: он подгоняет всегда.

По умолчанию autoFitView выключен (false). Приложение, которое само управляет областью просмотра — восстанавливает сохранённую позицию или вызывает focus на узле после загрузки, — не должно получать эту логику поверх своей при каждой раскладке. Для разовой подгонки вместо постоянного поведения вызовите метод напрямую:

ts
const map = useTemplateRef<AssetMapExposed>('map')
await map.value?.fitView()

MIT licensed