Раскладки
Алгоритм раскладки вычисляет позиции узлов свёрнутого графа. Два алгоритма встроены в пакет, третий лежит за отдельным подпутём, и любой объект с методом 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
Закреплённые узлы остаются на своих местах, остальные выстраиваются в детерминированную сетку. Подходит для случая, когда позиции приходят с бэкенда и координаты нужно сохранить, а не пересчитать.
{ engine: 'manual', options: { columns: 8, columnGap: 200, rowGap: 140, origin: { x: 0, y: 0 } } }columnGap и rowGap по умолчанию равны самому крупному узлу плюс 48 px, поэтому карточки разных размеров не наезжают друг на друга.
radial
Концентрические кольца по расстоянию от корня в рёбрах, поэтому номер кольца читается как число хопов.
{ engine: 'radial', options: { rootId: 'internet', radius: 220, ringGap: 180, startAngle: -Math.PI / 2 } }Без rootId центром становится узел наибольшей степени. Компоненты, недостижимые из корня, не выбрасываются: они получают кольцо за самым дальним связанным, поэтому изоляты остаются видимыми.
dagre
Ярусная раскладка, за своим подпутём, чтобы опциональная зависимость не попадала в основной бандл:
npm install @dagrejs/dagre<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.
Приоритет позиций
Конкурируют три источника, в таком порядке:
- позиция, полученная перетаскиванием узла,
entity.positionиз ваших данных,- результат алгоритма.
Первые два передаются алгоритму как pinned, и он не меняет их положение. Dagre не умеет фиксировать узлы, поэтому поверх его результата закреплённые позиции проставляются заново: снаружи поведение такое же, как у остальных алгоритмов.
Как запомнить расстановку
Перетащенные позиции уходят наружу через v-model:positions — обычный объект с ключами по id сущностей. Привяжите его, сохраните то, что выходит, верните при следующем открытии — и карта откроется ровно такой, какой её оставили.
<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:
<AssetMap
:entities="entities"
:links="links"
@node-move="({ id, position }) => api.patchAsset(id, position)"
/>Сохранить один сдвинутый актив — это другой запрос, чем сохранить всю раскладку, и так не приходится сравнивать два объекта, чтобы понять, какой именно узел переехал.
Вся картина целиком
getPositions() возвращает позиции всех видимых узлов, включая посчитанные алгоритмом, — снимок для экспорта или превью, в отличие от расстановки, которую выбрал человек:
const map = useTemplateRef<AssetMapExposed>('map')
const snapshot = map.value?.getPositions()Ключи — id узлов, поэтому свёрнутая группа отчитывается своим собственным id: её участников на экране нет, и позиции у них тоже нет. Результат — копия, её можно спокойно хранить и править.
resetPositions() забывает всё расставленное руками и возвращает карту алгоритму.
Свой алгоритм
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)
},
}<AssetMap :entities="entities" :links="links" :layout="{ engine: columnsLayout, options: { gap: 200 } }" />Позиции — это левые верхние углы в пикселях холста, по id узла. Узлы, которых нет в возвращённой карте, остаются на своих местах.
Обрабатывайте signal. Каждый запуск отменяет предыдущий, поэтому пользователь, трижды переключивший представление, увидит результат только последней раскладки. Для этого экспортируются throwIfAborted и yieldAndCheck. Устаревший результат отбрасывается, даже если сторонний алгоритм игнорирует сигнал, но алгоритм без проверок будет впустую жечь CPU на долгом расчёте.
Регистрация по имени
<AssetMap :entities="entities" :links="links" :layout="{ engine: 'columns' }" />Неизвестное имя алгоритма не фатально: карта откатывается на manual и сообщает об этом событием error, потому что имя может прийти из данных представления, то есть снаружи.
import { builtinLayoutEngines, resolveLayoutEngine } from 'vue-asset-map'
resolveLayoutEngine('radial') // radialLayout
resolveLayoutEngine('columns', { columns: columnsLayout })Ручной пересчёт
const map = useTemplateRef<AssetMapExposed>('map')
await map.value?.relayout()layout-start, layout-done и error сообщают о ходе запуска.
Автоподгонка
Без стартовой позиции непривязанный v-model:viewport по умолчанию равен { x: 0, y: 0, zoom: 1 }, поэтому то, что раскладка поставила в начало координат, оказывается в левом верхнем углу, а не по центру.
<AssetMap :entities="entities" :links="links" auto-fit-view />autoFitView подгоняет холст под содержимое, как только досчитается первая раскладка, и ещё раз — при смене представления.
На всякое изменение он намеренно не срабатывает. Свёртка группы, раскрытие поддерева, сужение фильтра — всё это меняет набор видимых узлов, и подгонка на них сдвигала бы вид, пока человек работает с одним углом графа: карта прыгает, и место теряется. Смена представления — другое дело: это другой граф, а прежний вид описывает раскладку, которой больше нет.
Момент подгонки выбирается по сигналу Vue Flow о том, что размеры узлов уже измерены, а не по таймауту. Поэтому она не центрирует карту, пока раскладка не досчитана. Явный вызов fitView() всё это не касается: он подгоняет всегда.
По умолчанию autoFitView выключен (false). Приложение, которое само управляет областью просмотра — восстанавливает сохранённую позицию или вызывает focus на узле после загрузки, — не должно получать эту логику поверх своей при каждой раскладке. Для разовой подгонки вместо постоянного поведения вызовите метод напрямую:
const map = useTemplateRef<AssetMapExposed>('map')
await map.value?.fitView()