Files
social-graph/frontend/src/views/GraphView.vue
T
gitrusprusandCursor 986d36ff51 Improve graph/map UX and move contact editing to dedicated pages.
Add viewport controls, collapsible panels with persisted state, filters modal, and refined context menus; preserve map layout on navigation and let contacts be edited with relations on /contacts/:id instead of list modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 15:15:45 +03:00

1247 lines
36 KiB
Vue

<template>
<div class="graph-view" :class="{ 'graph-view--chrome-collapsed': chromeCollapsed }">
<GraphHeaderPanel
v-show="!chromeCollapsed"
title="Граф связей"
:show-reset="false"
:show-physics-toggle="true"
:physics-enabled="physicsEnabled"
@toggle-physics="togglePhysics"
>
<template #actions>
<button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
Фильтры
</button>
</template>
</GraphHeaderPanel>
<GraphFiltersModal
:open="filtersOpen"
:relation-types="allRelationTypes"
:active-filters="activeFilters"
:link-selection-count="linkSelectionCount"
:link-selection="linkSelection"
:toolbar-actions="graphToolbarActions"
@close="filtersOpen = false"
@toggle="toggleFilter"
@toolbar-action="runGraphToolbarAction"
/>
<div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
<div class="graph-chrome-bar">
<button
type="button"
class="graph-chrome-toggle"
:title="chromeCollapsed ? 'Показать панели' : 'Свернуть панели'"
@click="toggleChrome"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
:class="{ 'graph-chrome-toggle__icon--collapsed': chromeCollapsed }"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
</button>
</div>
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
</svg>
<p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
</div>
<div v-else class="graph-stack" ref="graphStack">
<div class="graph-view-tools">
<button
type="button"
class="graph-fit-btn btn btn-secondary btn-sm"
title="По центру"
@click="fitView"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
По центру
</button>
<button
type="button"
class="graph-fullscreen-btn"
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
@click="toggleFullscreen"
>
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 14h6v6"/>
<path d="M20 10h-6V4"/>
<path d="M14 10l7-7"/>
<path d="M3 21l7-7"/>
</svg>
</button>
</div>
<div id="graph-container" ref="graphContainer"></div>
</div>
</div>
<!-- Node detail panel -->
<div v-if="selectedNode" class="modal-overlay" @click.self="selectedNode = null">
<div class="modal">
<div class="modal-header">
<h3>{{ selectedNode.label }}</h3>
<button class="btn btn-secondary btn-sm" @click="selectedNode = null"></button>
</div>
<div v-if="selectedContact">
<div class="form-group">
<label>Email</label>
<div>{{ selectedContact.email || '—' }}</div>
</div>
<div class="form-group">
<label>Телефон</label>
<div>{{ selectedContact.phone || '—' }}</div>
</div>
<div class="form-group">
<label>Организация / Должность</label>
<div>{{ [selectedContact.organization, selectedContact.position].filter(Boolean).join(' · ') || '—' }}</div>
</div>
<div class="form-group">
<label>Заметки</label>
<div>{{ selectedContact.notes || '—' }}</div>
</div>
<div class="form-group">
<label>Связей</label>
<div>{{ selectedContact.relations_count }}</div>
</div>
</div>
<div class="modal-footer">
<RouterLink :to="`/contacts/${selectedNode.id}`" class="btn btn-primary btn-sm">Открыть</RouterLink>
</div>
</div>
</div>
<GraphNodeContextMenu
:open="contextMenuOpen"
:node="contextMenuNode"
:x="contextMenuX"
:y="contextMenuY"
@close="closeContextMenu"
/>
<GraphEdgeContextMenu
:open="edgeContextMenuOpen"
:edge="contextMenuEdge"
:x="edgeContextMenuX"
:y="edgeContextMenuY"
@close="closeEdgeContextMenu"
@edit="openEditRelation"
/>
<GraphCanvasContextMenu
:open="canvasContextMenuOpen"
:x="canvasContextMenuX"
:y="canvasContextMenuY"
@close="closeContextMenu"
@create-contact="openCreateContact"
/>
<CreateContactModal
:open="createContactOpen"
:show-relation-link="graphContactOptions.length > 0"
:link-to-options="graphContactOptions"
:initial-link-to-id="linkSelection[0]?.id"
@close="createContactOpen = false"
@created="onContactCreated"
/>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<CreateRelationModal
:open="relationModalOpen"
:source="relationPair?.[0]"
:target="relationPair?.[1]"
@close="closeRelationModal"
@created="onRelationCreated"
/>
</div>
</template>
<script setup>
defineOptions({ name: 'Graph' })
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
import { RouterLink, useRouter, onBeforeRouteLeave } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import { clusterColor } from '../lib/graph/clusterColors'
import { computeClusterMap, updateClusterMapForNodes } from '../lib/graph/clusters'
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
import GraphFiltersModal from '../components/GraphFiltersModal.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
import CreateContactModal from '../components/CreateContactModal.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { getGraphToolbarActions } from '../core/pluginRegistry'
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'
let themeObserver = null
let detachContextHandler = null
const {
contextMenuOpen,
contextMenuNode,
contextMenuX,
contextMenuY,
edgeContextMenuOpen,
contextMenuEdge,
edgeContextMenuX,
edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openCanvasContextMenu,
closeContextMenu,
closeEdgeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
const createContactOpen = ref(false)
function openCreateContact() {
closeContextMenu()
createContactOpen.value = true
}
function onGraphAreaContextMenu(event) {
if (nodes.value.length > 0) return
openCanvasContextMenu(event)
}
async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
const created = await store.createContact(data)
if (mapIds?.length) {
await mapsStore.setContactMapMemberships(created.id, mapIds)
}
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(created.id, pluginPayload)
let relation = null
if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
relation = await store.createRelation({
source: created.id,
target: relationLink.targetId,
relation_type: relationLink.type,
description: relationLink.description || '',
interaction_intensity: relationLink.intensity,
})
}
createContactOpen.value = false
clearLinkSelection()
await ensureGraphReady({ showSpinner: false })
if (relation) appendRelationEdge(relation)
}
const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const router = useRouter()
const graphToolbarActions = getGraphToolbarActions()
const graphArea = ref(null)
const graphStack = ref(null)
const graphContainer = ref(null)
const loading = ref(true)
const isFullscreen = ref(false)
const filtersOpen = ref(false)
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
const relationModalOpen = ref(false)
const relationPair = ref(null)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const {
linkSelection,
linkSelectionCount,
clearLinkSelection,
handleCtrlPickNode,
} = useCtrlLinkSelection({
onPairSelected(pair) {
relationPair.value = pair
relationModalOpen.value = true
},
})
let nodesDS = null
let edgesDS = null
let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let syncedRevision = -1
let graphViewActive = false
let layoutSnapshotOnLeave = null
let initialLayoutDone = false
const nodes = ref([])
const edges = ref([])
const allRelationTypes = ref([])
const activeFilters = ref([])
const clusterMap = ref(new Map())
let prevLinkSelectionIds = new Set()
const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null
)
const graphContactOptions = computed(() =>
store.contacts.map((c) => ({
value: String(c.id),
label: [c.name, c.organization].filter(Boolean).join(' · '),
}))
)
function cssVar(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
function graphPalette() {
return {
nodeBackground: cssVar('--surface-alt', '#22263a'),
nodeBorder: cssVar('--accent', '#5b8dee'),
nodeLinkBorder: cssVar('--green', '#4ecca3'),
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
nodeFont: cssVar('--text', '#e2e6f3'),
edgeFont: cssVar('--text-muted', '#7b82a6'),
}
}
function recomputeClusters() {
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
}
function updateClustersLocal(seedNodeIds) {
const map = clusterMap.value
const affected = updateClusterMapForNodes(
map,
nodes.value,
filteredEdges(),
seedNodeIds
)
clusterMap.value = map
return affected
}
function refreshNodeStylesForIds(nodeIds) {
if (!nodesDS || !nodeIds?.size) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const livePositions = network.value?.getPositions() || {}
const idSet = new Set([...nodeIds].map(String))
const targets = nodes.value.filter((n) => idSet.has(String(n.id)))
if (!targets.length) return
nodesDS.update(
targets.map((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = livePositions[id]
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
})
)
network.value?.redraw()
}
function refreshNodeStyles() {
refreshNodeStylesForIds(new Set(nodes.value.map((n) => String(n.id))))
}
function withPhysicsPaused(fn) {
if (!network.value) {
fn()
return
}
const savedPositions = network.value.getPositions()
const savedView = {
position: network.value.getViewPosition(),
scale: network.value.getScale(),
}
const wasEnabled = physicsEnabled.value
if (wasEnabled) {
network.value.setOptions({ physics: physicsOptions(false) })
}
try {
fn()
} finally {
if (nodesDS && savedPositions) {
nodesDS.update(
Object.entries(savedPositions).map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
)
}
if (wasEnabled) {
network.value.setOptions({ physics: physicsOptions(true) })
}
if (savedView.position) {
network.value.moveTo({
position: savedView.position,
scale: savedView.scale || 1,
animation: false,
})
}
saveLayoutSnapshot()
}
}
function applyLocalEdgeChange(seedNodeIds, mutate) {
withPhysicsPaused(() => {
mutate()
const affected = updateClustersLocal(seedNodeIds)
const styleIds = new Set([...seedNodeIds].map(String))
affected.forEach((id) => styleIds.add(id))
refreshNodeStylesForIds(styleIds)
})
}
function nodeDegree(id) {
const sid = String(id)
return filteredEdges().filter(
(e) => String(e.from) === sid || String(e.to) === sid
).length
}
function isDarkTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark'
}
function nodeVisColor(nodeId, linkIds) {
const sid = String(nodeId)
const palette = graphPalette()
if (linkIds.has(sid)) {
return {
background: palette.nodeBackground,
border: palette.nodeLinkBorder,
highlight: {
background: palette.nodeHighlightBackground,
border: palette.nodeHighlightBorder,
},
}
}
const cc = clusterColor(clusterMap.value.get(sid), isDarkTheme())
return {
background: cc.bg,
border: cc.border,
highlight: { background: cc.highlight, border: cc.border },
}
}
function mapGraphNodeToVis(n, linkIds = new Set()) {
const palette = graphPalette()
const degree = nodeDegree(n.id)
const label = degree <= 12 ? (n.label || String(n.id)) : ''
return {
id: String(n.id),
label,
title: [n.label, n.title].filter(Boolean).join('\n'),
color: nodeVisColor(n.id, linkIds),
font: { color: palette.nodeFont, size: degree > 0 ? 12 : 11 },
shape: 'dot',
size: degree > 0 ? 10 + Math.min(degree, 4) * 1.5 : 8,
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
}
}
function applyLinkHighlights() {
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const affected = new Set([...linkIds, ...prevLinkSelectionIds])
prevLinkSelectionIds = linkIds
refreshNodeStylesForIds(affected)
}
function mapGraphEdgeToVis(e) {
const palette = graphPalette()
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
return {
id: String(e.id),
from: String(e.from),
to: String(e.to),
relation_type: e.relation_type,
interaction_intensity: e.interaction_intensity,
...intensity,
hoverWidth: intensity.width + 8,
selectionWidth: intensity.width + 12,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: false,
}
}
function resolveRelationForEdit(edge) {
const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
if (fromStore) return fromStore
const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
return {
id: edge.id,
source: edge.from,
target: edge.to,
relation_type: edge.relation_type,
description: edge.title && edge.title !== edge.relation_type ? edge.title : '',
interaction_intensity: edge.interaction_intensity || 'intense',
source_name: fromNode?.label || String(edge.from),
target_name: toNode?.label || String(edge.to),
}
}
function openEditRelation(edge) {
editRelationTarget.value = resolveRelationForEdit(edge)
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function updateGraphEdge(relation) {
const edge = edgeFromRelation(relation)
const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
if (idx !== -1) edges.value[idx] = edge
else edges.value.push(edge)
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
const visible =
activeFilters.value.length === allRelationTypes.value.length ||
activeFilters.value.includes(edge.relation_type)
if (!visible) {
if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
return
}
const visEdge = mapGraphEdgeToVis(edge)
if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
else edgesDS.add(visEdge)
}
function onRelationUpdated(relation) {
closeEditRelation()
syncedRevision = store.dataRevision
const edge = edgeFromRelation(relation)
const seedNodeIds = [edge.from, edge.to]
applyLocalEdgeChange(seedNodeIds, () => {
updateGraphEdge(relation)
})
}
function removeGraphEdge(relationId) {
const sid = String(relationId)
const removed = edges.value.find((e) => String(e.id) === sid)
const seedNodeIds = removed ? [removed.from, removed.to] : []
applyLocalEdgeChange(seedNodeIds, () => {
edges.value = edges.value.filter((e) => String(e.id) !== sid)
if (edgesDS?.get(sid)) edgesDS.remove(sid)
})
}
function onRelationDeleted(relationId) {
closeEditRelation()
syncedRevision = store.dataRevision
removeGraphEdge(relationId)
}
function appendRelationEdge(relation) {
if (!relation) return
const edge = edgeFromRelation(relation)
const seedNodeIds = [edge.from, edge.to]
applyLocalEdgeChange(seedNodeIds, () => {
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
edges.value.push(edge)
}
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
if (
activeFilters.value.length < allRelationTypes.value.length &&
!activeFilters.value.includes(edge.relation_type)
) {
return
}
if (!edgesDS.get(String(edge.id))) {
edgesDS.add(mapGraphEdgeToVis(edge))
}
})
}
function closeRelationModal() {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
}
function onRelationCreated(relation) {
syncedRevision = store.dataRevision
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
appendRelationEdge(relation)
}
watch(linkSelection, () => {
applyLinkHighlights()
}, { deep: true })
function physicsOptions(enabled) {
return {
enabled,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -120,
centralGravity: 0.002,
springLength: 200,
springConstant: 0.035,
damping: 0.5,
avoidOverlap: 1,
},
stabilization: enabled
? { iterations: 200, fit: !initialLayoutDone, updateInterval: 25 }
: undefined,
maxVelocity: 20,
}
}
async function applyGraphDataFromStore() {
if (!store.contacts.length) await store.fetchContacts()
if (!store.relations.length) await store.fetchRelations()
if (!store.relationTypes.length) {
allRelationTypes.value = await store.fetchRelationTypes()
} else {
allRelationTypes.value = store.relationTypes
}
const bundle = buildGraphFromStore(store.contacts, store.relations, allRelationTypes.value)
nodes.value = bundle.nodes
edges.value = bundle.edges
if (!activeFilters.value.length && allRelationTypes.value.length) {
activeFilters.value = allRelationTypes.value.map((r) => r.value)
}
recomputeClusters()
}
function saveLayoutSnapshot() {
if (!network.value) return
writeGraphLayoutCache({
positions: network.value.getPositions(),
scale: network.value.getScale(),
view: network.value.getViewPosition(),
})
}
function captureLayoutSnapshot() {
if (!network.value) return null
const view = network.value.getViewPosition()
return {
positions: { ...network.value.getPositions() },
scale: network.value.getScale(),
view: view ? { x: view.x, y: view.y } : null,
}
}
function applyLayoutSnapshot(snapshot) {
if (!network.value || !nodesDS || !snapshot) return
const updates = Object.entries(snapshot.positions || {})
.filter(([id]) => nodesDS.get(id))
.map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
if (updates.length) nodesDS.update(updates)
if (snapshot.view) {
network.value.moveTo({
position: snapshot.view,
scale: snapshot.scale || 1,
animation: false,
})
}
writeGraphLayoutCache(snapshot)
}
function resizeNetworkCanvas() {
if (!network.value || !graphContainer.value) return
const { offsetWidth: w, offsetHeight: h } = graphContainer.value
if (w > 10 && h > 10) {
network.value.setSize(`${w}px`, `${h}px`)
}
}
function restoreViewport() {
if (!network.value) return
const cache = readGraphLayoutCache()
if (cache.view) {
network.value.moveTo({
position: cache.view,
scale: cache.scale || 1,
animation: false,
})
}
}
function finishInitialLayout({ fitView = true } = {}) {
saveLayoutSnapshot()
initialLayoutDone = true
if (fitView) {
network.value?.fit({ animation: false })
}
network.value?.redraw()
}
function teardownNetwork() {
if (initRetryTimer) {
clearTimeout(initRetryTimer)
initRetryTimer = null
}
detachContextHandler?.()
detachContextHandler = null
resizeObserver?.disconnect()
resizeObserver = null
network.value?.destroy()
network.value = null
nodesDS = null
edgesDS = null
}
function syncGraphMetadataOnly(lockedPositions) {
if (!network.value || !nodesDS || !edgesDS) return
const positions = lockedPositions || network.value.getPositions()
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
nodesDS.getIds().forEach((id) => {
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
})
nodes.value.forEach((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = positions[id]
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
if (nodesDS.get(id)) nodesDS.update(payload)
else nodesDS.add(payload)
})
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
edgesDS.getIds().forEach((id) => {
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
})
nextEdges.forEach((edge) => {
if (edgesDS.get(edge.id)) edgesDS.update(edge)
else edgesDS.add(edge)
})
recomputeClusters()
}
function syncGraphToNetwork() {
if (!network.value || !nodesDS || !edgesDS) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const cachedPositions = readGraphLayoutCache().positions || {}
const livePositions = network.value.getPositions()
const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())
const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
nodesDS.getIds().forEach((id) => {
if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
})
nodes.value.forEach((n) => {
const id = String(n.id)
const vis = mapGraphNodeToVis(n, linkIds)
const pos = cachedPositions[id] || livePositions[id] || seeds.get(id)
const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
if (nodesDS.get(id)) nodesDS.update(payload)
else nodesDS.add(payload)
})
const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
edgesDS.getIds().forEach((id) => {
if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
})
nextEdges.forEach((edge) => {
if (edgesDS.get(edge.id)) edgesDS.update(edge)
else edgesDS.add(edge)
})
recomputeClusters()
refreshNodeStyles()
syncedRevision = store.dataRevision
restoreViewport()
}
let ensureGraphReadyInFlight = null
async function ensureGraphReady({ showSpinner = true } = {}) {
if (ensureGraphReadyInFlight) return ensureGraphReadyInFlight
ensureGraphReadyInFlight = (async () => {
const reconnecting = Boolean(network.value)
if (showSpinner && !reconnecting) loading.value = true
try {
await applyGraphDataFromStore()
} finally {
if (showSpinner && !reconnecting) loading.value = false
}
if (nodes.value.length === 0) {
teardownNetwork()
syncedRevision = store.dataRevision
return
}
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
if (!network.value) {
initNetwork()
syncedRevision = store.dataRevision
return
}
if (syncedRevision !== store.dataRevision) {
syncGraphToNetwork()
}
})().finally(() => {
ensureGraphReadyInFlight = null
})
return ensureGraphReadyInFlight
}
function filteredEdges() {
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
let list = edges.value.filter(
(e) => nodeIds.has(String(e.from)) && nodeIds.has(String(e.to)),
)
if (activeFilters.value.length < allRelationTypes.value.length) {
list = list.filter((e) => activeFilters.value.includes(e.relation_type))
}
return list
}
function initNetwork() {
if (network.value) return
if (!graphContainer.value) {
if (initRetryCount >= INIT_RETRY_MAX) return
initRetryCount += 1
initRetryTimer = setTimeout(initNetwork, 80)
return
}
const el = graphContainer.value
let width = el.offsetWidth
let height = el.offsetHeight
if (width < 10 || height < 10) {
if (initRetryCount >= INIT_RETRY_MAX) {
el.style.height = '400px'
el.style.width = '100%'
width = el.offsetWidth
height = el.offsetHeight
} else {
initRetryCount += 1
initRetryTimer = setTimeout(initNetwork, 80)
return
}
}
initRetryCount = 0
if (initRetryTimer) {
clearTimeout(initRetryTimer)
initRetryTimer = null
}
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const cache = readGraphLayoutCache()
const cachedPositions = cache.positions || {}
const nodeIds = nodes.value.map((n) => String(n.id))
const cachedCount = nodeIds.filter((id) => cachedPositions[id]).length
const useCachedLayout = cachedCount > 0 && cachedCount >= nodeIds.length * 0.8
const seedPositions = computeGraphSeedPositions(nodes.value, filteredEdges())
const nodeList = nodes.value.map((n) => {
const vis = mapGraphNodeToVis(n, linkIds)
const id = String(n.id)
const pos = cachedPositions[id] || seedPositions.get(id)
return pos ? { ...vis, x: pos.x, y: pos.y } : vis
})
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList)
if (useCachedLayout) {
initialLayoutDone = true
}
network.value = new Network(
el,
{ nodes: nodesDS, edges: edgesDS },
{
layout: { improvedLayout: false },
physics: physicsOptions(physicsEnabled.value),
interaction: {
tooltipDelay: 200,
hover: true,
hideEdgesOnDrag: true,
selectConnectedEdges: false,
zoomView: true,
dragView: true,
dragNodes: true,
},
edges: {
chosen: { label: false },
},
nodes: { borderWidth: 1.5 },
}
)
network.value.on('click', (params) => {
closeContextMenu()
if (params.nodes.length > 0) {
const id = params.nodes[0]
const node = nodes.value.find((n) => String(n.id) === id)
const domEvent = params.event?.srcEvent || params.event
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
handleCtrlPickNode(node, domEvent)
}
}
})
network.value.on('dragEnd', () => {
saveLayoutSnapshot()
})
detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(
network.value,
() => nodes.value,
() => edges.value
)
if (useCachedLayout) {
restoreViewport()
network.value.redraw()
finishInitialLayout({ fitView: false })
} else {
let layoutFinished = false
const completeLayout = () => {
if (layoutFinished) return
layoutFinished = true
finishInitialLayout({ fitView: true })
}
network.value.once('stabilizationIterationsDone', completeLayout)
network.value.once('stabilized', completeLayout)
setTimeout(completeLayout, 2500)
}
resizeObserver = new ResizeObserver(() => {
network.value?.redraw()
})
resizeObserver.observe(el)
}
function toggleFilter(type) {
if (activeFilters.value.includes(type)) {
if (activeFilters.value.length === 1) return
activeFilters.value = activeFilters.value.filter((f) => f !== type)
} else {
activeFilters.value.push(type)
}
if (edgesDS) {
recomputeClusters()
edgesDS.clear()
edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
refreshNodeStyles()
}
}
function applyThemeToNetwork() {
if (!nodesDS || !edgesDS || !network.value) return
refreshNodeStyles()
const palette = graphPalette()
edgesDS.update(
filteredEdges().map((e) => ({
id: String(e.id),
font: { color: palette.edgeFont, size: 10, align: 'middle' },
}))
)
network.value.redraw()
}
function fitView() {
network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
}
function togglePhysics() {
physicsEnabled.value = !physicsEnabled.value
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
}
async function toggleFullscreen() {
const el = graphStack.value
if (!el) return
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen()
} else {
await el.requestFullscreen()
}
} catch {
// Browser may block fullscreen without user gesture or in unsupported context.
}
}
function onFullscreenChange() {
isFullscreen.value = document.fullscreenElement === graphStack.value
nextTick(() => {
network.value?.redraw()
network.value?.fit({ animation: false })
})
}
function toggleChrome() {
chromeCollapsed.value = !chromeCollapsed.value
saveTopPanelCollapsed('graph', chromeCollapsed.value)
nextTick(() => network.value?.redraw())
}
function runGraphToolbarAction(action) {
action.onClick?.({ router })
}
watch(() => store.dataRevision, async (revision) => {
if (!network.value || !graphViewActive) return
await nextTick()
if (revision === syncedRevision) return
await applyGraphDataFromStore()
if (nodes.value.length === 0) {
teardownNetwork()
syncedRevision = revision
return
}
syncGraphToNetwork()
})
onMounted(() => {
themeObserver = new MutationObserver(() => applyThemeToNetwork())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
document.addEventListener('fullscreenchange', onFullscreenChange)
nextTick(() => {
if (!network.value) ensureGraphReady()
})
})
onBeforeRouteLeave(() => {
if (network.value && physicsEnabled.value) {
network.value.setOptions({ physics: physicsOptions(false) })
}
layoutSnapshotOnLeave = captureLayoutSnapshot()
saveLayoutSnapshot()
})
onActivated(async () => {
graphViewActive = true
const snapshot = layoutSnapshotOnLeave
layoutSnapshotOnLeave = null
if (network.value) {
network.value.setOptions({ physics: physicsOptions(false) })
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
resizeNetworkCanvas()
const lockedPositions = snapshot?.positions
|| readGraphLayoutCache().positions
|| {}
if (syncedRevision !== store.dataRevision) {
await applyGraphDataFromStore()
syncGraphMetadataOnly(lockedPositions)
syncedRevision = store.dataRevision
}
if (snapshot) {
applyLayoutSnapshot(snapshot)
} else {
restoreViewport()
}
if (physicsEnabled.value) {
network.value.setOptions({ physics: physicsOptions(true) })
}
network.value.redraw()
return
}
await ensureGraphReady()
})
onDeactivated(() => {
graphViewActive = false
if (network.value && physicsEnabled.value) {
network.value.setOptions({ physics: physicsOptions(false) })
}
if (!layoutSnapshotOnLeave) {
layoutSnapshotOnLeave = captureLayoutSnapshot()
}
saveLayoutSnapshot()
})
onUnmounted(() => {
saveLayoutSnapshot()
closeContextMenu()
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (document.fullscreenElement === graphStack.value) {
document.exitFullscreen().catch(() => {})
}
themeObserver?.disconnect()
teardownNetwork()
})
</script>
<style scoped>
.graph-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.graph-chrome-bar {
display: flex;
justify-content: center;
flex-shrink: 0;
margin-bottom: 6px;
}
.graph-chrome-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-chrome-toggle:hover {
color: var(--text);
border-color: var(--accent);
background: var(--surface-alt);
}
.graph-chrome-toggle svg {
transition: transform 0.2s ease;
}
.graph-chrome-toggle__icon--collapsed {
transform: rotate(180deg);
}
.graph-view--chrome-collapsed .graph-area {
padding-top: 4px;
}
.graph-area {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 28px 20px;
}
.graph-stack {
position: relative;
flex: 1;
min-height: 300px;
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.graph-stack:fullscreen {
border-radius: 0;
border: none;
background: var(--bg);
}
.graph-view-tools {
position: absolute;
top: 10px;
right: 10px;
z-index: 3;
display: flex;
align-items: center;
gap: 8px;
}
.graph-fit-btn {
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: var(--shadow);
}
.graph-fullscreen-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
box-shadow: var(--shadow);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-fullscreen-btn:hover {
color: var(--text);
border-color: var(--accent);
background: var(--surface-alt);
}
.graph-stack:fullscreen .graph-view-tools {
top: 16px;
right: 16px;
}
#graph-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
</style>