Traversal
The graph functions live in vue-asset-map/core, which imports neither Vue nor Vue Flow. They run in a browser, in a Node script and on a server alike.
import { buildGraph, neighbors, findPaths, blastRadius } from 'vue-asset-map/core'
const graph = buildGraph(entities, links)neighbors
Nodes within depth edges of an id:
neighbors(graph, 'keenetic-1', 1)
neighbors(graph, 'keenetic-1', 2, ['physical'])The node itself is never in the result, not even when a cycle makes it reachable from itself — callers want the neighbourhood, not its closure.
findPaths
Simple paths between two nodes:
const paths = findPaths(graph, 'ws-artjom', 'ws-vlad', {
maxDepth: 6,
maxPaths: 10,
layers: ['logical'],
})Paths are simple — no node repeats — so the walk terminates on any cycle. Result order is deterministic: links are visited in adjacency order, which is the order of the input array.
maxDepth is measured in edges, and defaults to 6; maxPaths defaults to 10.
blastRadius
What a node's failure reaches: the node itself plus everything influence spreads to.
blastRadius(graph, 'vps-tailscale', { direction: 'downstream', maxDepth: 2, layers: ['logical'] })direction only constrains directed links. An undirected link is walked both ways whatever direction says, because it carries no claim about which end depends on which. A dependency direction inferred from an undirected edge would be unreliable.
Highlighting a path on the map
Automatically, from the selection
autoPath is the whole feature: with exactly two nodes selected the map finds the route between them and highlights it, and the result leaves through path-change. Nothing to call, nothing to wire to a button.
<AssetMap
v-model:selection="selection"
:entities="entities"
:links="links"
auto-path
@path-change="(hops) => (path = hops)"
/>Shift-click is how a pair gets picked. A modified click only builds up the selection — it opens no details window, so choosing a second node does not bury the path under the windows you asked the map to draw it beneath. A plain click still opens one.
Shift is the modifier to reach for: it adds to the selection on every platform, whereas Ctrl+click does not reach a node's click handler at all on macOS, where it is the system's own right-click gesture and is dispatched as a contextmenu event instead of a click. Ctrl and Cmd are still honoured for anyone whose muscle memory expects them.
Anything other than exactly two selected nodes means there is no pair to connect, so the highlight goes away — the moment a third node joins the selection included. A selection set from outside works the same: two ids in v-model:selection trace a path without a click ever happening.
path-change fires only when the route actually changes, so it is safe to hang work off. It carries original entity ids in order, or an empty array when the highlight was dropped.
autoPath is off by default: a map that traces paths by itself would fight an application that highlights something of its own on selection.
By hand
highlightPath does the same thing on demand — for a pair that comes from somewhere other than the selection, an asset list or a search result:
const map = useTemplateRef<AssetMapExposed>('map')
const hops = map.value?.highlightPath('ws-artjom', 'ws-vlad') ?? []
map.value?.clearHighlight()It emits path-change too, so both routes into the feature report through one channel.
pathOptions bounds the search for both — { maxDepth, layers }, defaulting to 12 hops and the current view's layers. A third argument to highlightPath overrides it for that one call. The depth is bounded because the search walks the graph depth-first: on a dense map an unbounded one is what hangs the tab, and a route longer than that is not something a reader can follow on screen anyway.
The search runs on the original graph, restricted to the current view's layers, but the highlight is drawn on the collapsed one: where a stretch of the path is hidden inside a group, the group node and the edges leading to it light up instead. Otherwise the path would appear to break off. That is also why the trace is recomputed when a group folds shut or the view changes — the route depends on which links are on screen.
Show source
<script setup lang="ts">
import { ref } from 'vue'
import { AssetMap } from 'vue-asset-map'
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 selection = ref<string[]>(['ws-artjom', 'ws-vlad'])
const traced = ref<string[]>([])
// `autoPath` traces the pair itself, so there is no button to press and nothing
// to call on mount — the pre-selected pair is already highlighted on the first
// frame. The route arrives through `path-change` in original entity ids; it is
// searched on the original graph and drawn on the collapsed one, so a stretch
// hidden inside a group still lights up.
function onPathChange(path: string[]): void {
traced.value = path
}
// Clearing the selection is all it takes: the trace follows it.
function clear(): void {
selection.value = []
}
</script>
<template>
<AssetMap
v-model:selection="selection"
class="demo-map"
view="logical"
:entities="entities"
:links="links"
:views="views"
:theme="theme"
:locale="locale"
:theme-config="{ statusColors, kindIcons }"
:layout="{ engine: 'radial', options: { rootId: 'vps-tailscale' } }"
auto-path
auto-fit-view
@path-change="onPathChange"
>
<template #toolbar-right>
<span class="demo-toolbar-button demo-toolbar-button--hint">
Shift-click two nodes — the path traces itself
</span>
<button v-if="traced.length" type="button" class="demo-toolbar-button" @click="clear">
Clear · {{ traced.length }} hops
</button>
</template>
</AssetMap>
</template>
<style scoped>
.demo-toolbar-button--hint {
cursor: default;
color: var(--vp-c-text-2);
}
.demo-toolbar-button--hint:hover {
border-color: var(--vp-c-divider);
}
</style>
The infection overlay
blastRadius also backs the infection overlay: mark entities and the map shows everything they capture.
<AssetMap
:entities="entities"
:links="links"
:infected="['vps-tailscale']"
:infection-options="{ direction: 'downstream', maxDepth: 2 }"
/>An edge counts as infected only when both ends are. One infected end means the spread did not reach across it — direction or layer did not allow it — so colouring it would mark the very link the capture did not travel along.
A group node counts as infected when any single member is, or collapsing would conceal the infection.
The colors come from --vam-infected-bg, --vam-infected-border and --vam-infected-fg, and a hand-marked source is drawn heavier than the nodes it captured.
Membership markers
Markers are the same idea read at a glance: a dot under a label for every hub a node connects to.
import { connectionMarkers, topAnchors } from 'vue-asset-map/core'
const markers = connectionMarkers([
{ id: 'keenetic-1', label: 'Office A' },
{ id: 'vps-tailscale', label: 'VPN' },
])A workstation wired to a router physically and joined to the VPN logically gets two dots — belonging to one does not cancel the other. Layers are not restricted by default on purpose: the dots exist to show membership without switching views, and counting them per view would hide the very link you are looking for.
If the hubs are not known up front, take them by degree:
const markers = connectionMarkers(topAnchors(graph, 3))Show source
<script setup lang="ts">
import { ref } from 'vue'
import { AssetMap } from 'vue-asset-map'
import { connectionMarkers } from 'vue-asset-map/core'
import { useSiteTheme } from '../theme/useSiteTheme'
import { useSiteLocale } from '../theme/useSiteLocale'
import { entities, links, views, statusColors, kindIcons } from './fixture'
const theme = useSiteTheme()
const locale = useSiteLocale()
// One marker per hub; a node carries the dot of every hub it is connected to,
// so a workstation wired to a router and joined to the VPN gets two. Markers
// are not restricted to a layer, so switching the view here does not change
// which dots a node shows — that persistence is the point of the demo.
const markers = connectionMarkers([
{ id: 'keenetic-1', label: 'Keenetic 1' },
{ id: 'keenetic-2', label: 'Keenetic 2' },
{ id: 'vps-tailscale', label: 'Tailscale VPS' },
])
const view = ref('physical')
</script>
<template>
<AssetMap
v-model:view="view"
class="demo-map"
:entities="entities"
:links="links"
:views="views"
:markers="markers"
:marker-hover-delay="300"
:theme="theme"
:locale="locale"
:theme-config="{ statusColors, kindIcons }"
:layout="{ engine: 'radial', options: { rootId: view === 'physical' ? 'internet' : 'vps-tailscale' } }"
auto-fit-view
/>
</template>
Hovering a dot highlights every node carrying it and dims the rest, after markerHoverDelay (500 ms by default). The delay keeps a pointer that merely crosses the dots on its way elsewhere from flashing the whole map; keyboard focus highlights immediately, since focus is already a deliberate act.
Hierarchy helpers
import { ancestorsOf, descendantsOf, incidentLinks, otherEnd } from 'vue-asset-map/core'
ancestorsOf(graph, 'ct-web-1')
descendantsOf(graph, 'pve-beelink')
incidentLinks(graph, 'keenetic-1', ['physical'])Both walks are cycle-safe, descendantsOf returns depth-first order and excludes the node itself.